diff --git a/umap/static/umap/css/bar.css b/umap/static/umap/css/bar.css index 851f9a7c4..d6b143801 100644 --- a/umap/static/umap/css/bar.css +++ b/umap/static/umap/css/bar.css @@ -267,7 +267,7 @@ height: var(--control-size); } -.umap-edit-bar button { +.umap-edit-bar button[type="button"] { padding: 0; border-radius: 0; } diff --git a/umap/static/umap/css/form.css b/umap/static/umap/css/form.css index 071c4c327..188343392 100644 --- a/umap/static/umap/css/form.css +++ b/umap/static/umap/css/form.css @@ -94,12 +94,19 @@ select { border-width: 1px; } +.dark select[disabled], +select[disabled] { + background-color: var(--color-mediumGray); + color: var(--color-lightGray); + cursor: not-allowed; +} + select[multiple="multiple"] { height: auto; } .button, -[type="button"], +[type="button"]:not(.icon), input[type="submit"] { display: flex; align-items: center; @@ -155,16 +162,19 @@ input[type="submit"] { } button.flat, -[type="button"].flat, -.dark [type="button"].flat { - border: none; - background-color: inherit; +[type="button"].flat { padding: 0; text-align: start; min-height: inherit; width: initial; display: initial; - line-height: inherit; +} + +button.flat, +[type="button"].flat, +.dark [type="button"].flat { + border: none; + background-color: inherit; color: var(--text-color); } @@ -306,10 +316,14 @@ details summary { color: var(--color-light); } -.dark details fieldset { +.dark details fieldset:not(.formbox) { border: 1px solid var(--color-darkGray); } +summary h4 { + display: inline; +} + fieldset legend { font-size: .9rem; padding: 0 5px; @@ -755,3 +769,8 @@ input.highlightable:not(:placeholder-shown) { .umap-import [type=url] { margin-bottom: 0; } + +.select-with-actions { + display: flex; + align-items: center; +} diff --git a/umap/static/umap/css/icon.css b/umap/static/umap/css/icon.css index 55eb3608d..c3cb03d18 100644 --- a/umap/static/umap/css/icon.css +++ b/umap/static/umap/css/icon.css @@ -8,13 +8,16 @@ background-color: initial; } +[type=button].icon-16, +button.icon-16, .icon-16 { height: 24px; - line-height: 24px; width: 24px; --tile: -24px; } +[type=button].icon-24, +button.icon-24, .icon-24 { background-image: url('../img/24.svg'); --tile: -36px; @@ -128,7 +131,7 @@ html[dir="rtl"] .icon { .icon-drag { background-position: calc(var(--tile) * 3) calc(var(--tile) * 3); - cursor: move; + cursor: grab; float: inline-end; visibility: hidden; } @@ -165,6 +168,35 @@ html[dir="rtl"] .icon { background-position: calc(var(--tile) * 5) 0; } +.icon-field-Boolean { + background-position: calc(var(--tile) * 2) calc(var(--tile) * 8); +} + +.icon-field-Date { + background-position: calc(var(--tile) * 5) calc(var(--tile) * 8); +} + +.icon-field-Datetime { + background-position: calc(var(--tile) * 6) calc(var(--tile) * 8); +} + +.icon-field-Enum { + background-position: calc(var(--tile) * 7) calc(var(--tile) * 8); +} + +.icon-field-Number { + background-position: calc(var(--tile) * 3) calc(var(--tile) * 8); +} + +.icon-field-String { + background-position: var(--tile) calc(var(--tile) * 8); +} + +.icon-field-Text { + background-position: 0 calc(var(--tile) * 8); +} + + .icon-filters { background-position: 0px var(--tile); } diff --git a/umap/static/umap/css/panel.css b/umap/static/umap/css/panel.css index 768437be1..c3374e8ab 100644 --- a/umap/static/umap/css/panel.css +++ b/umap/static/umap/css/panel.css @@ -72,6 +72,17 @@ background-color: var(--color-veryDarkGray); } +.panel .umap-filter legend { + display: flex; + justify-content: space-between; +} + +.panel .filter-toolbox { + position: absolute; + right: 25px; + background-color: var(--background-color); +} + @media all and (orientation:landscape) { .panel { top: var(--current-header-height); diff --git a/umap/static/umap/img/16-white.svg b/umap/static/umap/img/16-white.svg index 3bd5130a5..fdf0c06e6 100644 --- a/umap/static/umap/img/16-white.svg +++ b/umap/static/umap/img/16-white.svg @@ -1,4 +1,4 @@ - + @@ -214,10 +214,18 @@ - - - - + + + + + + + + + + + + diff --git a/umap/static/umap/img/16.svg b/umap/static/umap/img/16.svg index 944c655bd..71d3db3cd 100644 --- a/umap/static/umap/img/16.svg +++ b/umap/static/umap/img/16.svg @@ -1 +1 @@ -image/svg+xml continue +image/svg+xml continue diff --git a/umap/static/umap/img/source/16-white.svg b/umap/static/umap/img/source/16-white.svg index 8fce7e8d0..095403702 100644 --- a/umap/static/umap/img/source/16-white.svg +++ b/umap/static/umap/img/source/16-white.svg @@ -1,7 +1,7 @@ - + @@ -25,7 +25,7 @@ - + @@ -206,15 +206,15 @@ - - - - - - - - - + + + + + + + + + @@ -225,11 +225,19 @@ - + - - + + + + + + + + + + diff --git a/umap/static/umap/img/source/16.svg b/umap/static/umap/img/source/16.svg index 22dca2f70..0163c9ee6 100644 --- a/umap/static/umap/img/source/16.svg +++ b/umap/static/umap/img/source/16.svg @@ -1,4 +1,4 @@ -image/svg+xml continue +image/svg+xml continue diff --git a/umap/static/umap/js/components/alerts/alert.css b/umap/static/umap/js/components/alerts/alert.css index 6e181a831..56b3c7989 100644 --- a/umap/static/umap/js/components/alerts/alert.css +++ b/umap/static/umap/js/components/alerts/alert.css @@ -1,7 +1,5 @@ .umap-alert[role="dialog"] { box-sizing: border-box; - min-height: 46px; - line-height: 46px; padding: var(--box-padding); position: absolute; box-shadow: 0 1px 7px #999999; @@ -25,8 +23,12 @@ margin: 0 auto; min-width: 60%; background-size: 20px; - background-position: 0 15px; padding-inline-start: 28px; + background-position: 0 center; + min-height: 10vh; + display: flex; + flex-direction: column; + justify-content: center; } .umap-alert[role="dialog"][data-level="info"]>div { @@ -34,24 +36,20 @@ background-repeat: no-repeat; } -html[dir="rtl"] .umap-alert[role="dialog"][data-level="info"]>div { - background-position: right; -} - .umap-alert[role="dialog"][data-level="success"]>div { background-image: url('../../../img/alert-icon-success.svg'); background-repeat: no-repeat; } -html[dir="rtl"] .umap-alert[role="dialog"][data-level="success"]>div { - background-position: right; -} - +.umap-alert[role="dialog"][data-level="warning"]>div, .umap-alert[role="dialog"][data-level="error"]>div { background-image: url('../../../img/alert-icon-error.svg'); background-repeat: no-repeat; } +html[dir="rtl"] .umap-alert[role="dialog"][data-level="info"]>div, +html[dir="rtl"] .umap-alert[role="dialog"][data-level="success"]>div, +html[dir="rtl"] .umap-alert[role="dialog"][data-level="warning"]>div, html[dir="rtl"] .umap-alert[role="dialog"][data-level="error"]>div { background-position: right; } @@ -60,6 +58,10 @@ html[dir="rtl"] .umap-alert[role="dialog"][data-level="error"]>div { background-color: var(--color-darkRed); } +.umap-alert[role="dialog"][data-level="warning"] { + background-color: var(--color-darkOrange); +} + .umap-alert[role="dialog"] a { text-decoration: underline; } @@ -130,13 +132,11 @@ h3[role="alert"]+p { [role="group"] input[type="button"] { background: var(--color-darkGray); color: var(--color-light); - border: none; line-height: initial; } [role="group"] input[type="button"]:hover { text-decoration: underline; - border: none; } @media only screen and (max-width:770px) { diff --git a/umap/static/umap/js/components/alerts/alert.js b/umap/static/umap/js/components/alerts/alert.js index af2bf8627..d3cd8253e 100644 --- a/umap/static/umap/js/components/alerts/alert.js +++ b/umap/static/umap/js/components/alerts/alert.js @@ -22,6 +22,11 @@ class uMapAlert extends uMapElement { uMapAlert.emit('alert', { level: 'success', message, duration }) } + // biome-ignore lint/style/useNumberNamespace: Number.Infinity returns undefined by default + static warning(message, duration = Infinity) { + uMapAlert.emit('alert', { level: 'warning', message, duration }) + } + // biome-ignore lint/style/useNumberNamespace: Number.Infinity returns undefined by default static error(message, duration = Infinity) { uMapAlert.emit('alert', { level: 'error', message, duration }) diff --git a/umap/static/umap/js/modules/browser.js b/umap/static/umap/js/modules/browser.js index 09a5feaed..0d47f10f2 100644 --- a/umap/static/umap/js/modules/browser.js +++ b/umap/static/umap/js/modules/browser.js @@ -124,7 +124,11 @@ export default class Browser { } hasFilters() { - return !!this.options.filter || this._umap.facets.isActive() + return ( + !!this.options.filter || + this._umap.filters.isActive() || + this._umap.datalayers.active().some((d) => d.filters.isActive()) + ) } onMoveEnd() { @@ -151,7 +155,10 @@ export default class Browser {

${translate('Data browser')}

- ${translate('Filters')} + + ${translate('Filters')} + +
@@ -177,6 +184,7 @@ export default class Browser { dataContainer, formContainer, reset, + add, }, ] = Utils.loadTemplateWithRefs(template) // HOTFIX. Remove when this is released: @@ -187,43 +195,53 @@ export default class Browser { fitBounds.addEventListener('click', () => this._umap.fitDataBounds()) download.addEventListener('click', () => this.downloadVisible(download)) download.hidden = this._umap.getProperty('embedControl') === false + reset.addEventListener('click', () => this.resetFilters()) + add.addEventListener('click', () => { + this._umap.edit().then((panel) => panel.scrollTo('details#fields-management')) + this._umap.filters.filterForm() + }) this.filtersTitle = filtersTitle this.dataContainer = dataContainer this.formContainer = formContainer this.toggleBadge() + this.buildFilters() + this._umap.panel.open({ + content: container, + className: 'umap-browser', + }) - let fields = [ + this.update() + } + + buildFilters() { + this.formContainer.innerHTML = '' + const fields = [ [ 'options.filter', { handler: 'Input', placeholder: translate('Search map features…') }, ], ['options.inBbox', { handler: 'Switch', label: translate('Current map view') }], ] - const builder = new Form(this, fields) - builder.on('set', () => this.onFormChange()) - let filtersBuilder - this.formContainer.appendChild(builder.build()) - builder.form.addEventListener('reset', () => { - window.setTimeout(builder.syncAll.bind(builder)) - }) - if (this._umap.properties.facetKey) { - fields = this._umap.facets.build() - filtersBuilder = new Form(this._umap.facets, fields) - filtersBuilder.on('set', () => this.onFormChange()) - filtersBuilder.form.addEventListener('reset', () => { - window.setTimeout(filtersBuilder.syncAll.bind(filtersBuilder)) + const searchForm = new Form(this, fields) + const listenFormChanges = (form) => { + form.on('set', () => this.onFormChange()) + form.form.addEventListener('reset', () => { + window.setTimeout(form.syncAll.bind(form)) }) - this.formContainer.appendChild(filtersBuilder.build()) } - reset.addEventListener('click', () => this.resetFilters()) - - this._umap.panel.open({ - content: container, - className: 'umap-browser', - }) - - this.update() + this.formContainer.appendChild(searchForm.build()) + listenFormChanges(searchForm) + if (this._umap.filters.size) { + const filtersForm = this._umap.filters.buildForm(this.formContainer) + listenFormChanges(filtersForm) + } + for (const datalayer of this._umap.datalayers.active()) { + if (datalayer.filters.size) { + const filtersForm = datalayer.filters.buildForm(this.formContainer) + listenFormChanges(filtersForm) + } + } } resetFilters() { diff --git a/umap/static/umap/js/modules/caption.js b/umap/static/umap/js/modules/caption.js index f0618a8cc..ce163735a 100644 --- a/umap/static/umap/js/modules/caption.js +++ b/umap/static/umap/js/modules/caption.js @@ -39,7 +39,7 @@ export default class Caption extends Utils.WithTemplate { this._leafletMap = leafletMap this.loadTemplate(TEMPLATE) this.elements.star.addEventListener('click', async () => { - if (this._umap.properties.user?.id) { + if (this._umap.permissions.userIsAuth()) { await this._umap.star() this.refresh() } else { diff --git a/umap/static/umap/js/modules/data/features.js b/umap/static/umap/js/modules/data/features.js index eb68de15f..8049fbba3 100644 --- a/umap/static/umap/js/modules/data/features.js +++ b/umap/static/umap/js/modules/data/features.js @@ -92,7 +92,7 @@ class Feature { get fields() { // Fields are user defined properties - return [...this.datalayer.fields, ...this._umap.fields] + return [...this.datalayer.fields.all(), ...this._umap.fields.all()] } setter(key, value) { @@ -234,7 +234,7 @@ class Feature { ) let builder = new MutatingForm(this, [ - ['datalayer', { handler: 'DataLayerSwitcher' }], + ['datalayer', { handler: 'EditableDataLayerSwitcher' }], ]) // removeLayer step will close the edit panel, let's reopen it builder.on('set', () => this.edit(event)) @@ -242,10 +242,21 @@ class Feature { const properties = [] for (const field of this.fields) { - const options = { handler: 'Input', label: field.key } + const options = { + handler: 'Input', + label: `${field.key}`, + } if (field.key === 'description' || field.type === 'Text') { options.handler = 'Textarea' options.helpEntries = ['textFormatting'] + } else if (field.type === 'Number') { + options.handler = 'FloatInput' + } else if (field.type === 'Date') { + options.handler = 'DateInput' + } else if (field.type === 'Datetime') { + options.handler = 'DateTimeInput' + } else if (field.type === 'Boolean') { + options.handler = 'Switch' } properties.push([`properties.${field.key}`, options]) } @@ -258,7 +269,7 @@ class Feature { `` ) button.addEventListener('click', () => { - this.datalayer.addProperty().then(() => this.edit({ force: true })) + this.datalayer.fields.editField().then(() => this.edit({ force: true })) }) form.appendChild(button) this.appendEditFieldsets(container) @@ -499,16 +510,16 @@ class Feature { return properties } - deleteProperty(property) { - const oldValue = this.properties[property] - delete this.properties[property] - this.sync.update(`properties.${property}`, undefined, oldValue) + deleteField(name) { + const oldValue = this.properties[name] + delete this.properties[name] + this.sync.update(`properties.${name}`, undefined, oldValue) } - renameProperty(from, to) { + renameField(from, to) { const oldValue = this.properties[from] this.properties[to] = this.properties[from] - this.deleteProperty(from) + this.deleteField(from) this.sync.update(`properties.${to}`, oldValue, undefined) } @@ -524,12 +535,13 @@ class Feature { isFiltered() { const filterKeys = this.datalayer.getFilterKeys() const filter = this._umap.browser.options.filter - if (filter && !this.matchFilter(filter, filterKeys)) return true - if (!this.matchFacets()) return true + if (filter && !this.matchFullTextFilter(filter, filterKeys)) return true + if (!this.matchMapFilters()) return true + if (!this.matchLayerFilters()) return true return false } - matchFilter(filter, keys) { + matchFullTextFilter(filter, keys) { filter = filter.toLowerCase() // When user hasn't touched settings, when a feature has no name // it will use the datalayer's name, so let's make the filtering @@ -547,28 +559,53 @@ class Feature { return false } - matchFacets() { - const selected = this._umap.facets.selected - for (const [name, { type, min, max, choices }] of Object.entries(selected)) { - let value = this.properties[name] - const parser = this._umap.facets.getParser(type) + _mapFilters(fields, filters) { + for (const [key, { min, max, choices }] of Object.entries(filters.selected)) { + // This filter has no value selected by the user. + if (min === undefined && max === undefined && !choices?.length) continue + const field = fields.get(key) + // This field may only exist on another layer. + if (!field) continue + let value = this.properties[key] + const parser = filters.getParser(field.type) value = parser(value) - switch (type) { - case 'date': - case 'datetime': - case 'number': - if (!Number.isNaN(min) && !Number.isNaN(value) && min > value) return false - if (!Number.isNaN(max) && !Number.isNaN(value) && max < value) return false + switch (field.type) { + case 'Date': + case 'Datetime': + case 'Number': + if (!Number.isNaN(min) && !Number.isNaN(value) && min > value) { + return false + } + if (!Number.isNaN(max) && !Number.isNaN(value) && max < value) { + return false + } + break + case 'Enum': { + const intersection = value.filter((item) => choices.includes(item)) + if (intersection.length !== choices.length) { + return false + } break + } default: value = value || translate('') - if (choices?.length && !choices.includes(value)) return false + if (choices?.length && !choices.includes(value)) { + return false + } break } } return true } + matchMapFilters() { + return this._mapFilters(this._umap.fields, this._umap.filters) + } + + matchLayerFilters() { + return this._mapFilters(this.datalayer.fields, this.datalayer.filters) + } + isMulti() { return false } diff --git a/umap/static/umap/js/modules/data/layer.js b/umap/static/umap/js/modules/data/layer.js index 6f87046c9..5e270760c 100644 --- a/umap/static/umap/js/modules/data/layer.js +++ b/umap/static/umap/js/modules/data/layer.js @@ -9,7 +9,7 @@ import { uMapAlert as Alert, uMapAlertConflict as AlertConflict, } from '../../components/alerts/alert.js' -import { MutatingForm } from '../form/builder.js' +import { MutatingForm, Form } from '../form/builder.js' import { translate } from '../i18n.js' import { DataLayerPermissions } from '../permissions.js' import { Default as DefaultLayer } from '../rendering/layers/base.js' @@ -21,8 +21,8 @@ import TableEditor from '../tableeditor.js' import * as Utils from '../utils.js' import { LineString, Point, Polygon } from './features.js' import Rules from '../rules.js' -import Orderable from '../orderable.js' -import { FeatureManager } from '../managers.js' +import { FeatureManager, FieldManager } from '../managers.js' +import Filters from '../filters.js' export const LAYER_TYPES = [ DefaultLayer, @@ -89,12 +89,14 @@ export class DataLayer { if (!this.createdOnServer) { if (this.showAtLoad()) this.show() } - if (!this._needsFetch && !this._umap.fields.length) { + if (!this._needsFetch && !this._umap.fields.size) { this.properties.fields = [ { key: U.DEFAULT_LABEL_KEY, type: 'String' }, { key: 'description', type: 'Text' }, ] } + this.fields = new FieldManager(this, this._umap.dialog) + this.filters = new Filters(this, this._umap) // Only layers that are displayed on load must be hidden/shown // Automatically, others will be shown manually, and thus will @@ -146,17 +148,10 @@ export class DataLayer { this.properties.rank = value } - get fields() { - if (!this.properties.fields) this.properties.fields = [] - return this.properties.fields - } - - set fields(fields) { - this.properties.fields = fields - } - get fieldKeys() { - return this.fields.map((field) => field.key) + // Needed to get a similar API from layer and uMap, but + // uMap whould return concat of all datalayers fields + return Array.from(this.fields.keys()) } get sortKey() { @@ -174,6 +169,13 @@ export class DataLayer { // Propagate will remove the fields it has already // processed fields = this.propagate(fields) + if (fields.includes('properties.fields')) this.fields.pull() + if (fields.includes('properties.filters')) { + this.filters.load() + if (this._umap.browser.isOpen()) { + this._umap.browser.buildFilters() + } + } const impacts = Utils.getImpactsFromSchema(fields) @@ -482,88 +484,45 @@ export class DataLayer { } inferFields(feature) { - if (!this.properties.fields) this.properties.fields = [] - const keys = this.fieldKeys for (const key in feature.properties) { if (typeof feature.properties[key] !== 'object') { if (key.indexOf('_') === 0) continue - if (keys.includes(key)) continue - this.properties.fields.push({ key, type: 'String' }) + if (this.fields.has(key)) continue + if (this._umap.fields.has(key)) continue + // retrocompat: guess type from filters if any + // otherwise it will fallback to default in filters + let type = this._umap.filters.get(key)?.dataType + if (!type && key === 'description') type = 'Text' + this.fields.add({ key, type }) } } + this.fields.push() } - async confirmDeleteProperty(property) { - return this._umap.dialog - .confirm( - translate('Are you sure you want to delete this field on all the features?') - ) - .then(() => { - this.deleteProperty(property) - }) - } - - async askForRenameProperty(property) { - return this._umap.dialog - .prompt(translate('Please enter the new name of this field')) - .then(({ prompt }) => { - if (!prompt || !this.validateName(prompt)) return - this.renameProperty(property, prompt) - }) + renameField(oldName, newName) { + this.renameFeaturesField(oldName, newName) } - renameProperty(oldName, newName) { - this.sync.startBatch() - const oldFields = Utils.CopyJSON(this.fields) - for (const field of this.fields) { - if (field.key === oldName) { - field.key = newName - break - } - } - this.sync.update('properties.fields', this.fields, oldFields) + renameFeaturesField(oldName, newName) { this.features.forEach((feature) => { - feature.renameProperty(oldName, newName) + feature.renameField(oldName, newName) }) - this.sync.commitBatch() } - deleteProperty(property) { - this.sync.startBatch() - const oldFields = Utils.CopyJSON(this.fields) - this.fields = this.fields.filter((field) => field.key !== property) - this.sync.update('properties.fields', this.fields, oldFields) - this.features.forEach((feature) => { - feature.deleteProperty(property) - }) - this.sync.commitBatch() + deleteField(name) { + this.deleteFeaturesField(name) } - addProperty() { - let resolve = undefined - const promise = new Promise((r) => { - resolve = r - }) - this._umap.dialog - .prompt(translate('Please enter the name of the property')) - .then(({ prompt }) => { - if (!prompt || !this.validateName(prompt)) return - this.properties.fields.push({ key: prompt, type: 'String' }) - resolve() + deleteFeaturesField(name) { + if (!this._umap.fields.has(name) && !this.fields.has(name)) { + this.features.forEach((feature) => { + feature.deleteField(name) }) - return promise + } } - validateName(name) { - if (name.includes('.')) { - Alert.error(translate('Name “{name}” should not contain a dot.', { name })) - return false - } - if (this.fieldKeys.includes(name)) { - Alert.error(translate('This name already exists: “{name}”', { name })) - return false - } - return true + eachFeature(callback) { + this.features.forEach((feature) => callback(feature)) } sortedValues(property) { @@ -928,64 +887,6 @@ export class DataLayer { fieldset.appendChild(builder.build()) } - _editFields(container) { - const template = ` -
- ${translate('Manage Fields')} -
-
    - -
    -
    - ` - const [fieldset, { ul, add }] = Utils.loadTemplateWithRefs(template) - add.addEventListener('click', () => { - this.addProperty().then(() => { - this.edit().then((panel) => { - panel.scrollTo('details#fields') - }) - }) - }) - container.appendChild(fieldset) - for (const field of this.fields) { - const [row, { rename, del }] = Utils.loadTemplateWithRefs( - `
  • - - - - ${field.key} -
  • ` - ) - ul.appendChild(row) - rename.addEventListener('click', () => { - this.askForRenameProperty(field.key).then(() => { - this.edit().then((panel) => { - panel.scrollTo('details#fields') - }) - }) - }) - del.addEventListener('click', () => { - this.confirmDeleteProperty(field.key).then(() => { - this.edit().then((panel) => { - panel.scrollTo('details#fields') - }) - }) - }) - } - const onReorder = (src, dst, initialIndex, finalIndex) => { - const orderedKeys = Array.from(ul.querySelectorAll('li')).map( - (el) => el.dataset.key - ) - const oldFields = Utils.CopyJSON(this.properties.fields) - this.properties.fields.sort( - (fieldA, fieldB) => - orderedKeys.indexOf(fieldA.key) > orderedKeys.indexOf(fieldB.key) - ) - this.sync.update('properties.fields', this.properties.fields, oldFields) - } - const orderable = new Orderable(ul, onReorder) - } - _editRemoteDataProperties(container) { // XXX I'm not sure **why** this is needed (as it's set during `this.initialize`) // but apparently it's needed. @@ -1084,6 +985,46 @@ export class DataLayer { if (this.createdOnServer) download.hidden = false } + _editFieldsAndKeys(parent) { + const body = Utils.loadTemplate(` +
    +

    ${translate('Fields, filters and keys')}

    +
    +
    +
    + `) + parent.appendChild(body) + + if (!this.isRemoteLayer()) { + const fieldsContainer = Utils.loadTemplate(` +
    + ${translate('Manage Fields')} +
    + `) + + body.appendChild(fieldsContainer) + this.fields.edit(fieldsContainer) + } + + const template = ` +
    + ${translate('Keys management')} +
    +
    + ` + const [root, { keyContainer }] = Utils.loadTemplateWithRefs(template) + const optionsFields = [ + 'properties.labelKey', + 'properties.sortKey', + // 'properties.filterKey', + ] + body.appendChild(root) + + const builder = new MutatingForm(this, optionsFields, { umap: this }) + keyContainer.appendChild(builder.build()) + this.filters.edit(body) + } + edit() { if (!this._umap.editEnabled) { return @@ -1096,9 +1037,7 @@ export class DataLayer { this._editInteractionProperties(container) this._editTextPathProperties(container) this._editRemoteDataProperties(container) - if (!this.isRemoteLayer()) { - this._editFields(container) - } + this._editFieldsAndKeys(container) this.rules.edit(container) if (this._umap.properties.urls.datalayer_versions) { @@ -1438,13 +1377,12 @@ export class DataLayer { )) { container.innerHTML = '' if (this.layer.renderLegend) return this.layer.renderLegend(container) - const keys = new Set(this.fieldKeys) const rules = new Map() for (const rule of this.rules) { rules.set(rule.condition, rule) } for (const rule of this._umap.rules) { - if (!rules.has(rule.condition) && keys.has(rule.key)) { + if (!rules.has(rule.condition) && this.fields.has(rule.key)) { rules.set(rule.condition, rule) } } diff --git a/umap/static/umap/js/modules/facets.js b/umap/static/umap/js/modules/facets.js deleted file mode 100644 index 19b9720f8..000000000 --- a/umap/static/umap/js/modules/facets.js +++ /dev/null @@ -1,164 +0,0 @@ -import { DomEvent, DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' -import { translate } from './i18n.js' -import * as Utils from './utils.js' - -export default class Facets { - constructor(umap) { - this._umap = umap - this.selected = {} - } - - compute(names, defined) { - const properties = {} - let selected - - for (const name of names) { - const type = defined.get(name).type - properties[name] = { type: type } - selected = this.selected[name] || {} - selected.type = type - if (!['date', 'datetime', 'number'].includes(type)) { - properties[name].choices = [] - selected.choices = selected.choices || [] - } - this.selected[name] = selected - } - - this._umap.datalayers.browsable().map((datalayer) => { - datalayer.features.forEach((feature) => { - for (const name of names) { - let value = feature.properties[name] - const type = defined.get(name).type - const parser = this.getParser(type) - value = parser(value) - switch (type) { - case 'date': - case 'datetime': - case 'number': - if (!Number.isNaN(value)) { - // Special cases where we want to be lousy when checking isNaN without - // coercing to a Number first because we handle multiple types. - // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/ - // Reference/Global_Objects/Number/isNaN - // biome-ignore lint/suspicious/noGlobalIsNan: see above. - if (isNaN(properties[name].min) || properties[name].min > value) { - properties[name].min = value - } - // biome-ignore lint/suspicious/noGlobalIsNan: see above. - if (isNaN(properties[name].max) || properties[name].max < value) { - properties[name].max = value - } - } - break - default: - value = value || translate('') - if (!properties[name].choices.includes(value)) { - properties[name].choices.push(value) - } - } - } - }) - }) - return properties - } - - isActive() { - for (const { type, min, max, choices } of Object.values(this.selected)) { - if (min !== undefined || max !== undefined || choices?.length) { - return true - } - } - return false - } - - build() { - const defined = this.getDefined() - const names = [...defined.keys()] - const facetProperties = this.compute(names, defined) - - const fields = names.map((name) => { - const criteria = facetProperties[name] - let handler = 'FacetSearchChoices' - switch (criteria.type) { - case 'number': - handler = 'FacetSearchNumber' - break - case 'date': - handler = 'FacetSearchDate' - break - case 'datetime': - handler = 'FacetSearchDateTime' - break - } - const label = defined.get(name).label - return [ - `selected.${name}`, - { - criteria: criteria, - handler: handler, - label: label, - }, - ] - }) - - return fields - } - - getDefined() { - const defaultType = 'checkbox' - const allowedTypes = [defaultType, 'radio', 'number', 'date', 'datetime'] - const defined = new Map() - if (!this._umap.properties.facetKey) return defined - return (this._umap.properties.facetKey || '').split(',').reduce((acc, curr) => { - let [name, label, type] = curr.split('|') - type = allowedTypes.includes(type) ? type : defaultType - acc.set(name, { label: label || name, type: type }) - return acc - }, defined) - } - - getParser(type) { - switch (type) { - case 'number': - return Number.parseFloat - case 'datetime': - return (v) => new Date(v) - case 'date': - return Utils.parseNaiveDate - default: - return (v) => String(v || '') - } - } - - dumps(parsed) { - const dumped = [] - for (const [property, { label, type }] of parsed) { - dumped.push([property, label, type].filter(Boolean).join('|')) - } - const oldValue = this._umap.properties.facetKey - this._umap.properties.facetKey = dumped.join(',') - this._umap.sync.update( - 'properties.facetKey', - this._umap.properties.facetKey, - oldValue - ) - } - - 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.dumps(defined) - } - } - - remove(property) { - const defined = this.getDefined() - defined.delete(property) - this.dumps(defined) - } -} diff --git a/umap/static/umap/js/modules/filters.js b/umap/static/umap/js/modules/filters.js new file mode 100644 index 000000000..5751c3445 --- /dev/null +++ b/umap/static/umap/js/modules/filters.js @@ -0,0 +1,597 @@ +import { translate } from './i18n.js' +import { Form } from './form/builder.js' +import * as Utils from './utils.js' +import Orderable from './orderable.js' +import { Fields } from './form/fields.js' + +const WIDGETS = ['checkbox', 'radio', 'minmax'] + +class FiltersForm extends Form { + buildField(field) { + const [root, elements] = field.buildTemplate() + elements.editFilter.addEventListener('click', field.properties.onClick) + field.build() + } + + getHelperTemplate(helper) { + return helper.getTemplate() + } +} + +export default class Filters { + constructor(parent, umap) { + this._parent = parent + this._umap = umap + this.selected = {} + this.load() + } + + get size() { + return this.defined.size + } + + isActive() { + for (const { type, min, max, choices } of Object.values(this.selected)) { + if (min !== undefined || max !== undefined || choices?.length) { + return true + } + } + return false + } + + // Loop on the data to compute the list of choices, min + // and max values. + compute() { + const properties = Object.fromEntries(this.defined.keys().map((name) => [name, {}])) + + for (const name of this.defined.keys()) { + const field = this._parent.fields.get(name) + if (!field) continue + properties[name].choices ??= new Set() + const parser = this.getParser(field.type) + this._parent.eachFeature((feature) => { + let value = feature.properties[name] + value = parser(value) + switch (field.type) { + case 'Date': + case 'Datetime': + case 'Number': + if (!Number.isNaN(value)) { + // Special cases where we want to be lousy when checking isNaN without + // coercing to a Number first because we handle multiple types. + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/ + // Reference/Global_Objects/Number/isNaN + // biome-ignore lint/suspicious/noGlobalIsNan: see above. + if (isNaN(properties[name].min) || properties[name].min > value) { + properties[name].min = value + } + // biome-ignore lint/suspicious/noGlobalIsNan: see above. + if (isNaN(properties[name].max) || properties[name].max < value) { + properties[name].max = value + } + } + break + case 'Enum': + properties[name].choices = Array.from( + new Set([...properties[name].choices, ...value]) + ) + break + default: + value = value || translate('') + properties[name].choices.add(value) + } + }) + } + return properties + } + + buildFormFields() { + const filterProperties = this.compute() + + const formFields = [] + for (const name of this.defined.keys()) { + const criteria = filterProperties[name] || {} + const field = this._parent.fields.get(name) + if (!field) continue + const type = field.type + let handler = 'FilterByChoices' + if (criteria.widget === 'minmax' || criteria.widget === undefined) { + if (type === 'Number') { + handler = 'FilterByNumber' + } else if (type === 'Date') { + handler = 'FilterByDate' + } else if (type === 'Datetime') { + handler = 'FilterByDateTime' + } + } + const label = ` + ${Utils.escapeHTML(this.defined.get(name).label || field.key)} + + + ` + formFields.push([ + `selected.${name}`, + { + criteria: { ...criteria, choices: Array.from(criteria.choices) }, + handler: handler, + label: label, + onClick: () => { + this._parent + .edit() + .then((panel) => panel.scrollTo('details#fields-management')) + this._parent.filters.filterForm(name) + }, + }, + ]) + } + return formFields + } + + load() { + this.defined = new Map(Object.entries(this._parent.properties.filters || {})) + this.loadLegacy() + } + + loadLegacy() { + const legacy = + this._parent.properties.advancedFilterKey || this._parent.properties.facetKey + if (!legacy) return + for (const filter of legacy.split(',')) { + let [key, label, widget] = filter.split('|') + let type = 'String' + if (['number', 'date', 'datetime'].includes(widget)) { + // Retrocompat + if (widget === 'number') { + type = 'Number' + } else if (widget === 'datetime') { + type = 'Datetime' + } else if (widget === 'date') { + type = 'Date' + } + widget = 'minmax' + } + if (!WIDGETS.includes(widget)) { + widget = 'checkbox' + } + this.defined.set(key, { label: label || key, widget }) + if (!this._parent.fields.has(key)) { + this._parent.fields.add({ key, type }) + } + } + delete this._parent.properties.facetKey + delete this._parent.properties.advancedFilterKey + this.dumps(false) + this._parent._migrated = true + } + + getParser(type) { + switch (type) { + case 'Number': + return Number.parseFloat + case 'Datetime': + return (v) => new Date(v) + case 'Date': + return Utils.parseNaiveDate + case 'Enum': + return (v) => + String(v || '') + .split(',') + .map((s) => s.trim()) + default: + return (v) => String(v || '') + } + } + + dumps(sync = true) { + const oldValue = this._parent.properties.filters + this._parent.properties.filters = Object.fromEntries(this.defined.entries()) + if (sync) { + this._parent.sync.update( + 'properties.filters', + this._parent.properties.filters, + oldValue + ) + this._parent.render(['properties.filters']) + } + } + + has(name) { + return this.defined.has(name) + } + + get(name) { + return this.defined.get(name) + } + + add({ name, label, widget }) { + if (!this.defined.has(name)) { + this.update({ name, label, widget }) + } + } + + update({ name, label, widget }) { + this.defined.set(name, { label, widget }) + this.dumps() + } + + remove(name) { + this.defined.delete(name) + this.dumps() + } + + edit(container) { + const template = ` +
    + ${translate('Filters')} +
      + +
      + ` + const [body, { ul, add }] = Utils.loadTemplateWithRefs(template) + if (!this._parent.fields.size) { + add.disabled = true + ul.appendChild( + Utils.loadTemplate( + `
    • ${translate('Add a field prior to create a filter.')}
    • ` + ) + ) + } + this._umap.help.parse(body) + this.defined.forEach((props, key) => { + const [li, { edit, remove }] = Utils.loadTemplateWithRefs( + `
    • + + + + ${props.label || key} +
    • ` + ) + ul.appendChild(li) + remove.addEventListener('click', () => { + this.remove(key) + this._parent.edit().then((panel) => panel.scrollTo('details#fields-management')) + }) + edit.addEventListener('click', () => { + this.filterForm(key) + }) + }) + add.addEventListener('click', () => this.filterForm()) + const onReorder = (src, dst, initialIndex, finalIndex) => { + const orderedKeys = Array.from(ul.querySelectorAll('li')).map( + (el) => el.dataset.key + ) + const oldValue = Utils.CopyJSON(this._parent.properties.filters) + const copy = Object.fromEntries(this.defined) + this.defined.clear() + for (const key of orderedKeys) { + this.add({ name: key, ...copy[key] }) + } + this._parent.sync.update( + 'properties.filters', + this._parent.properties.filters, + oldValue + ) + } + const orderable = new Orderable(ul, onReorder) + container.appendChild(body) + } + + filterForm(name) { + let widget = WIDGETS[0] + const field = this._parent.fields.get(name) + if (['Number', 'Date', 'Datetime'].includes(field?.type)) { + widget = 'minmax' + } + const properties = { + target: this._parent, + name, + widget, + ...(this.defined.get(name) || {}), + } + const fieldKeys = name + ? [name] + : ['', ...this._parent.fieldKeys.filter((key) => !this.defined.has(key))] + const metadata = [ + [ + 'target', + { + handler: 'FilterTargetSelect', + label: translate('Apply filter to'), + disabled: Boolean(name), + }, + ], + [ + 'name', + { + handler: 'Select', + selectOptions: fieldKeys, + label: translate('Filter on'), + }, + ], + [ + 'label', + { handler: 'Input', label: translate('Human readable name of the filter') }, + ], + [ + 'widget', + { + handler: 'MultiChoice', + choices: WIDGETS, + label: translate('Widget for the filter'), + }, + ], + ] + const form = new Form(properties, metadata, { umap: this._umap }) + let label + if (name) { + label = translate('Edit filter') + } else { + label = translate('Add filter') + } + + const [container, { body, editField }] = Utils.loadTemplateWithRefs(` +
      +

      ${label}

      +
      + +
      + `) + body.appendChild(form.build()) + editField.addEventListener('click', () => { + this._umap.dialog.accept() + this._parent.fields.editField(name) + }) + + return this._umap.dialog.open({ template: container }).then(() => { + const target = properties.target + if (!properties.name) return + if (name) { + target.filters.update({ ...properties }) + } else { + target.filters.add({ ...properties }) + } + target.filters._parent + .edit() + .then((panel) => panel.scrollTo('details#fields-management')) + }) + } + + buildForm(container) { + const form = new FiltersForm(this, this.buildFormFields()) + container.appendChild(form.build()) + return form + } +} + +Fields.FilterBase = class extends Fields.Base { + buildLabel() {} +} + +Fields.FilterByChoices = class extends Fields.FilterBase { + getTemplate() { + return ` +
      + ${this.properties.label} +
        +
        + ` + } + + build() { + this.type = this.properties.criteria.widget || 'checkbox' + + const choices = this.properties.criteria.choices + choices.sort() + choices.forEach((value) => this.buildLi(value)) + super.build() + } + + buildLi(value) { + const name = `${this.type}_${this.name}` + const [li, { input, label }] = Utils.loadTemplateWithRefs(` +
      • + +
      • + `) + label.textContent = value + input.checked = this.get()?.choices?.includes(value) + input.dataset.value = value + input.addEventListener('change', () => this.sync()) + this.elements.ul.appendChild(li) + } + + toJS() { + return { + type: this.type, + choices: [...this.elements.ul.querySelectorAll('input:checked')].map( + (i) => i.dataset.value + ), + } + } +} + +Fields.MinMaxBase = class extends Fields.FilterBase { + getInputType(type) { + return type + } + + getLabels() { + return [translate('Min'), translate('Max')] + } + + prepareForHTML(value) { + return value?.valueOf() ?? null + } + + getTemplate() { + const [minLabel, maxLabel] = this.getLabels() + const { min, max, widget } = this.properties.criteria + this.type = widget + const inputType = this.getInputType(this.type) + const minHTML = this.prepareForHTML(min) + const maxHTML = this.prepareForHTML(max) + return ` +
        + ${this.properties.label} + + +
        + ` + } + + build() { + this.minInput = this.elements.minInput + this.maxInput = this.elements.maxInput + const { min, max, type } = this.properties.criteria + const { min: modifiedMin, max: modifiedMax } = this.get() || {} + + const currentMin = modifiedMin !== undefined ? modifiedMin : min + const currentMax = modifiedMax !== undefined ? modifiedMax : max + if (min != null) { + // The value stored using setAttribute is not modified by + // user input, and will be used as initial value when calling + // form.reset(), and can also be retrieve later on by using + // getAttributing, to compare with current value and know + // if this value has been modified by the user + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/reset + this.minInput.setAttribute('value', this.prepareForHTML(min)) + this.minInput.value = this.prepareForHTML(currentMin) + } + + if (max != null) { + // Cf comment above about setAttribute vs value + this.maxInput.setAttribute('value', this.prepareForHTML(max)) + this.maxInput.value = this.prepareForHTML(currentMax) + } + this.toggleStatus() + + this.minInput.addEventListener('change', () => this.sync()) + this.maxInput.addEventListener('change', () => this.sync()) + super.build() + } + + toggleStatus() { + this.minInput.dataset.modified = this.isMinModified() + this.maxInput.dataset.modified = this.isMaxModified() + } + + sync() { + super.sync() + this.toggleStatus() + } + + isMinModified() { + const default_ = this.minInput.getAttribute('value') + const current = this.minInput.value + return current !== default_ + } + + isMaxModified() { + const default_ = this.maxInput.getAttribute('value') + const current = this.maxInput.value + return current !== default_ + } + + toJS() { + const opts = { + type: this.type, + } + if (this.minInput.value !== '' && this.isMinModified()) { + opts.min = this.prepareForJS(this.minInput.value) + } + if (this.maxInput.value !== '' && this.isMaxModified()) { + opts.max = this.prepareForJS(this.maxInput.value) + } + return opts + } +} + +Fields.FilterByNumber = class extends Fields.MinMaxBase { + getInputType(type) { + return 'number' + } + + prepareForJS(value) { + return new Number(value) + } +} + +Fields.FilterByDate = class extends Fields.MinMaxBase { + getInputType(type) { + return 'date' + } + + prepareForJS(value) { + return new Date(value) + } + + toLocaleDateTime(dt) { + return new Date(dt.valueOf() - dt.getTimezoneOffset() * 60000) + } + + prepareForHTML(value) { + // Value must be in local time + if (!value || isNaN(value)) return + return this.toLocaleDateTime(value).toISOString().substr(0, 10) + } + + getLabels() { + return [translate('From'), translate('Until')] + } +} + +Fields.FilterByDateTime = class extends Fields.FilterByDate { + getInputType() { + return 'datetime-local' + } + + prepareForHTML(value) { + // Value must be in local time + if (Number.isNaN(value)) return + return this.toLocaleDateTime(value).toISOString().slice(0, -1) + } +} + +Fields.FilterTargetSelect = class extends Fields.Select { + getOptions() { + const options = [] + if (this.builder.properties.umap.fields.size) { + options.push([ + `map:${this.builder.properties.umap.id}`, + `${this.builder.properties.umap.properties.name} (${translate('all layers')})`, + ]) + } + this.builder.properties.umap.datalayers.reverse().map((datalayer) => { + if (datalayer.isBrowsable() && datalayer.fields.size) { + options.push([ + `layer:${datalayer.id}`, + `${datalayer.getName()} (${translate('single layer')})`, + ]) + } + }) + return options + } + + toHTML() { + if (!this.obj.target) return null + // TODO: better way to check for class + // Importing DataLayer will end in circular import + const type = this.obj.target._umap ? 'layer' : 'map' + return `${type}:${this.obj.target?.id}` + } + + toJS() { + const value = this.value() + if (!value) return null + const [type, id] = value.split(':') + if (type === 'map') { + return this.builder.properties.umap + } + return this.builder.properties.umap.datalayers[id] + } +} diff --git a/umap/static/umap/js/modules/form/builder.js b/umap/static/umap/js/modules/form/builder.js index 01c971da8..920819274 100644 --- a/umap/static/umap/js/modules/form/builder.js +++ b/umap/static/umap/js/modules/form/builder.js @@ -1,7 +1,7 @@ import { translate } from '../i18n.js' import { SCHEMA } from '../schema.js' import * as Utils from '../utils.js' -import getClass from './fields.js' +import { getClass } from './fields.js' export class Form extends Utils.WithEvents { constructor(obj, fields, properties) { @@ -107,14 +107,18 @@ export class Form extends Utils.WithEvents { finish() {} - getTemplate(helper) { + getHelperTemplate(helper) { let tpl = helper.getTemplate() if (helper.properties.label && !tpl.includes(helper.properties.label)) { tpl = `` } + return tpl + } + + getTemplate(helper) { return `
        - ${tpl} + ${this.getHelperTemplate(helper)}
        ` } @@ -132,7 +136,6 @@ export class MutatingForm extends Form { const customHandlers = { sortKey: 'PropertyInput', easing: 'Switch', - facetKey: 'PropertyInput', slugKey: 'PropertyInput', labelKey: 'PropertyInput', color: 'ColorPicker', diff --git a/umap/static/umap/js/modules/form/fields.js b/umap/static/umap/js/modules/form/fields.js index 55473fc44..bd368cfd7 100644 --- a/umap/static/umap/js/modules/form/fields.js +++ b/umap/static/umap/js/modules/form/fields.js @@ -8,15 +8,15 @@ import * as Icon from '../rendering/icon.js' import { SCHEMA } from '../schema.js' import * as Utils from '../utils.js' -const Fields = {} +export const Fields = {} -export default function getClass(name) { +export function getClass(name) { if (typeof name === 'function') return name if (!Fields[name]) throw Error(`Unknown class ${name}`) return Fields[name] } -class BaseElement { +Fields.Base = class { constructor(builder, field, properties) { this.builder = builder this.obj = this.builder.obj @@ -49,6 +49,7 @@ class BaseElement { this.elements = elements this.container = elements.container this.form.appendChild(this.root) + return [root, elements] } getTemplate() { @@ -113,9 +114,7 @@ class BaseElement { getLabelTemplate() { const label = this.properties.label const help = this.properties.helpEntries?.join() || '' - return label - ? `` - : '' + return label ? `` : '' } fetch() {} @@ -129,7 +128,7 @@ class BaseElement { } } -Fields.Textarea = class extends BaseElement { +Fields.Textarea = class extends Fields.Base { getTemplate() { return `` } @@ -169,7 +168,7 @@ Fields.Textarea = class extends BaseElement { } } -Fields.Input = class extends BaseElement { +Fields.Input = class extends Fields.Base { getTemplate() { return `` } @@ -296,7 +295,41 @@ Fields.BlurFloatInput = class extends FloatMixin(Fields.BlurInput) { } } -Fields.CheckBox = class extends BaseElement { +const DateMixin = (Base) => + class extends Base { + toHTML() { + const raw = super.toHTML() + if (!raw) return null + const parsed = Utils.parseNaiveDate(raw) + if (!parsed) return null + return parsed.toISOString().substring(0, 10) + } + + type() { + return 'date' + } + } + +Fields.DateInput = class extends DateMixin(Fields.Input) {} + +const DateTimeMixin = (Base) => + class extends Base { + toHTML() { + const raw = super.toHTML() + if (!raw) return null + const datetime = new Date(raw) + if (isNaN(datetime)) return null + return datetime.toISOString() + } + + type() { + return 'datetime-local' + } + } + +Fields.DateTimeInput = class extends DateTimeMixin(Fields.Input) {} + +Fields.CheckBox = class extends Fields.Base { getTemplate() { return `` } @@ -327,7 +360,7 @@ Fields.CheckBox = class extends BaseElement { } } -Fields.CheckBoxes = class extends BaseElement { +Fields.CheckBoxes = class extends Fields.Base { getInputTemplate(value, label) { return `` } @@ -350,13 +383,16 @@ Fields.CheckBoxes = class extends BaseElement { } } -Fields.Select = class extends BaseElement { +Fields.Select = class extends Fields.Base { getTemplate() { return `` } build() { this.select = this.elements.select + if (this.properties.disabled) { + this.select.disabled = true + } this.validValues = [] this.buildOptions() this.select.addEventListener('change', () => this.sync()) @@ -423,7 +459,7 @@ Fields.IntSelect = class extends Fields.Select { } } -Fields.EditableText = class extends BaseElement { +Fields.EditableText = class extends Fields.Base { getTemplate() { return `` } @@ -579,12 +615,9 @@ Fields.SlideshowDelay = class extends Fields.IntSelect { } } -Fields.DataLayerSwitcher = class extends Fields.Select { +const BaseDataLayerSwitcher = class extends Fields.Select { getOptions() { const options = [] - if (this.properties.allowEmpty) { - options.push([null, translate('Import in a new layer')]) - } this.builder._umap.datalayers.reverse().map((datalayer) => { if ( datalayer.isLoaded() && @@ -611,6 +644,36 @@ Fields.DataLayerSwitcher = class extends Fields.Select { } } +Fields.NullableDataLayerSwitcher = class extends BaseDataLayerSwitcher { + getOptions() { + const options = super.getOptions() + options.unshift([null, translate('Import in a new layer')]) + return options + } +} + +Fields.EditableDataLayerSwitcher = class extends BaseDataLayerSwitcher { + getTemplate() { + return ` +
        + ${super.getTemplate()} + + +
        + ` + } + + build() { + super.build() + this.elements.openEditPanel.addEventListener('click', () => { + this.obj.datalayer.edit() + }) + this.elements.openTableEditor.addEventListener('click', () => { + this.obj.datalayer.tableEdit() + }) + } +} + Fields.DataFormat = class extends Fields.Select { getOptions() { return [ @@ -967,7 +1030,7 @@ Fields.Switch = class extends Fields.CheckBox { getTemplate() { const label = this.properties.label const help = this.properties.helpEntries?.join() || '' - return `${super.getTemplate()}` + return `${super.getTemplate()}` } build() { @@ -989,191 +1052,7 @@ Fields.Switch = class extends Fields.CheckBox { } } -Fields.FacetSearchBase = class extends BaseElement { - buildLabel() {} -} - -Fields.FacetSearchChoices = class extends Fields.FacetSearchBase { - getTemplate() { - return ` -
        - ${Utils.escapeHTML(this.properties.label)} -
          -
          - ` - } - - build() { - this.type = this.properties.criteria.type - - const choices = this.properties.criteria.choices - choices.sort() - choices.forEach((value) => this.buildLi(value)) - super.build() - } - - buildLi(value) { - const name = `${this.type}_${this.name}` - const [li, { input, label }] = Utils.loadTemplateWithRefs(` -
        • - -
        • - `) - label.textContent = value - input.checked = this.get().choices.includes(value) - input.dataset.value = value - input.addEventListener('change', () => this.sync()) - this.elements.ul.appendChild(li) - } - - toJS() { - return { - type: this.type, - choices: [...this.elements.ul.querySelectorAll('input:checked')].map( - (i) => i.dataset.value - ), - } - } -} - -Fields.MinMaxBase = class extends Fields.FacetSearchBase { - getInputType(type) { - return type - } - - getLabels() { - return [translate('Min'), translate('Max')] - } - - prepareForHTML(value) { - return value.valueOf() - } - - getTemplate() { - const [minLabel, maxLabel] = this.getLabels() - const { min, max, type } = this.properties.criteria - this.type = type - const inputType = this.getInputType(this.type) - const minHTML = this.prepareForHTML(min) - const maxHTML = this.prepareForHTML(max) - return ` -
          - ${Utils.escapeHTML(this.properties.label)} - - -
          - ` - } - - build() { - this.minInput = this.elements.minInput - this.maxInput = this.elements.maxInput - const { min, max, type } = this.properties.criteria - const { min: modifiedMin, max: modifiedMax } = this.get() - - const currentMin = modifiedMin !== undefined ? modifiedMin : min - const currentMax = modifiedMax !== undefined ? modifiedMax : max - if (min != null) { - // The value stored using setAttribute is not modified by - // user input, and will be used as initial value when calling - // form.reset(), and can also be retrieve later on by using - // getAttributing, to compare with current value and know - // if this value has been modified by the user - // https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/reset - this.minInput.setAttribute('value', this.prepareForHTML(min)) - this.minInput.value = this.prepareForHTML(currentMin) - } - - if (max != null) { - // Cf comment above about setAttribute vs value - this.maxInput.setAttribute('value', this.prepareForHTML(max)) - this.maxInput.value = this.prepareForHTML(currentMax) - } - this.toggleStatus() - - this.minInput.addEventListener('change', () => this.sync()) - this.maxInput.addEventListener('change', () => this.sync()) - super.build() - } - - toggleStatus() { - this.minInput.dataset.modified = this.isMinModified() - this.maxInput.dataset.modified = this.isMaxModified() - } - - sync() { - super.sync() - this.toggleStatus() - } - - isMinModified() { - const default_ = this.minInput.getAttribute('value') - const current = this.minInput.value - return current !== default_ - } - - isMaxModified() { - const default_ = this.maxInput.getAttribute('value') - const current = this.maxInput.value - return current !== default_ - } - - toJS() { - const opts = { - type: this.type, - } - if (this.minInput.value !== '' && this.isMinModified()) { - opts.min = this.prepareForJS(this.minInput.value) - } - if (this.maxInput.value !== '' && this.isMaxModified()) { - opts.max = this.prepareForJS(this.maxInput.value) - } - return opts - } -} - -Fields.FacetSearchNumber = class extends Fields.MinMaxBase { - prepareForJS(value) { - return new Number(value) - } -} - -Fields.FacetSearchDate = class extends Fields.MinMaxBase { - prepareForJS(value) { - return new Date(value) - } - - toLocaleDateTime(dt) { - return new Date(dt.valueOf() - dt.getTimezoneOffset() * 60000) - } - - prepareForHTML(value) { - // Value must be in local time - if (Number.isNaN(value)) return - return this.toLocaleDateTime(value).toISOString().substr(0, 10) - } - - getLabels() { - return [translate('From'), translate('Until')] - } -} - -Fields.FacetSearchDateTime = class extends Fields.FacetSearchDate { - getInputType(type) { - return 'datetime-local' - } - - prepareForHTML(value) { - // Value must be in local time - if (Number.isNaN(value)) return - return this.toLocaleDateTime(value).toISOString().slice(0, -1) - } -} - -Fields.MultiChoice = class extends BaseElement { +Fields.MultiChoice = class extends Fields.Base { getDefault() { return 'null' } @@ -1207,7 +1086,10 @@ Fields.MultiChoice = class extends BaseElement { } getChoices() { - return this.properties.choices || this.choices + let choices = this.properties.choices || this.choices + // Allow to pass flat arrays [c1, c2, c3] instead of [[c1, v1], [c2, v2]] + if (!Array.isArray(choices[0])) choices = choices.map((key) => [key, key]) + return choices } getTemplate() { @@ -1322,7 +1204,7 @@ Fields.Range = class extends Fields.FloatInput { } } -Fields.ManageOwner = class extends BaseElement { +Fields.ManageOwner = class extends Fields.Base { build() { super.build() const options = { @@ -1353,7 +1235,7 @@ Fields.ManageOwner = class extends BaseElement { } } -Fields.ManageEditors = class extends BaseElement { +Fields.ManageEditors = class extends Fields.Base { build() { super.build() const options = { diff --git a/umap/static/umap/js/modules/help.js b/umap/static/umap/js/modules/help.js index ca3009697..03ba80fc3 100644 --- a/umap/static/umap/js/modules/help.js +++ b/umap/static/umap/js/modules/help.js @@ -92,8 +92,8 @@ const ENTRIES = { filterKey: translate( 'Comma separated list of properties to use when filtering features by text input' ), - facetKey: translate( - 'Comma separated list of properties to use for filters (eg.: mykey,otherkey). To control label, add it after a | (eg.: mykey|My Key,otherkey|Other Key). To control input field type, add it after another | (eg.: mykey|My Key|checkbox,otherkey|Other Key|datetime). Allowed values for the input field type are checkbox (default), radio, number, date and datetime.' + filters: translate( + 'Filters will be displayed in the data browser to ease data selection.' ), interactive: translate( 'If false, the polygon or line will act as a part of the underlying map.' diff --git a/umap/static/umap/js/modules/importers/openrouteservice.js b/umap/static/umap/js/modules/importers/openrouteservice.js index 536da0a3d..cb2408c6a 100644 --- a/umap/static/umap/js/modules/importers/openrouteservice.js +++ b/umap/static/umap/js/modules/importers/openrouteservice.js @@ -37,7 +37,7 @@ export class Importer { } const metadatas = [ - ['datalayer', { handler: 'DataLayerSwitcher', allowEmpty: true }], + ['datalayer', { handler: 'NullableDataLayerSwitcher'}], [ 'profile', { handler: 'Select', selectOptions: PROFILES, label: translate('Profile') }, diff --git a/umap/static/umap/js/modules/managers.js b/umap/static/umap/js/modules/managers.js index c44da91e5..25251da21 100644 --- a/umap/static/umap/js/modules/managers.js +++ b/umap/static/umap/js/modules/managers.js @@ -1,4 +1,8 @@ import * as Utils from './utils.js' +import { translate } from './i18n.js' +import Orderable from './orderable.js' +import { uMapAlert as Alert } from '../components/alerts/alert.js' +import { Form } from './form/builder.js' export class DataLayerManager extends Object { add(datalayer) { @@ -59,7 +63,7 @@ export class FeatureManager extends Map { if (this.has(feature.id)) { console.error('Duplicate id', feature, this.get(feature.id)) feature.id = Utils.generateId() - feature.datalayer._found_duplicate_id = true + feature.datalayer._migrated = true } this.set(feature.id, feature) } @@ -112,3 +116,230 @@ export class FeatureManager extends Map { return this.all()[index - 1] } } + +export class FieldManager extends Map { + constructor(parent, dialog) { + super() + this.parent = parent + this.dialog = dialog + this.parent.properties.fields ??= [] + this.pull() + } + + pull() { + this.clear() + for (const field of this.parent.properties.fields) { + this.add(field) + } + } + + push() { + this.parent.properties.fields = this.all().map((field) => { + // We don't want to keep the reference, otherwise editing + // it will also change the old value + return { ...field } + }) + } + + async commit() { + return new Promise((resolve) => { + const oldFields = Utils.CopyJSON(this.parent.properties.fields) + resolve() + this.push() + this.parent.sync.update( + 'properties.fields', + this.parent.properties.fields, + oldFields + ) + }) + } + + add(field) { + if (!field?.key) { + console.error('Invalid field', field) + return + } + field.type ??= 'String' + // Copy object, so not to affect original + // when edited. + this.set(field.key, { ...field }) + this.push() + } + + delete(key) { + super.delete(key) + this.push() + } + + all() { + return Array.from(this.values()) + } + + edit(container) { + const ul = Utils.loadTemplate('
            ') + const add = Utils.loadTemplate( + `` + ) + add.addEventListener('click', () => { + this.editField().then(() => { + this.parent.edit().then((panel) => { + panel.scrollTo('details#fields-management') + }) + }) + }) + container.appendChild(ul) + container.appendChild(add) + for (const field of this.all()) { + const [row, { edit, del }] = Utils.loadTemplateWithRefs( + `
          • + + + + + ${field.key} +
          • ` + ) + ul.appendChild(row) + edit.addEventListener('click', () => { + this.editField(field.key).then(() => { + this.parent.edit().then((panel) => { + panel.scrollTo('details#fields-management') + }) + }) + }) + del.addEventListener('click', () => { + this.confirmDelete(field.key).then(() => { + this.parent.edit().then((panel) => { + panel.scrollTo('details#fields-management') + }) + }) + }) + } + const onReorder = (src, dst, initialIndex, finalIndex) => { + const orderedKeys = Array.from(ul.querySelectorAll('li')).map( + (el) => el.dataset.key + ) + const oldFields = Utils.CopyJSON(this.parent.properties.fields) + const copy = Object.fromEntries(this) + this.clear() + for (const key of orderedKeys) { + this.add(copy[key]) + } + this.parent.sync.update( + 'properties.fields', + this.parent.properties.fields, + oldFields + ) + } + const orderable = new Orderable(ul, onReorder) + } + + async editField(name) { + const FIELD_TYPES = [ + 'String', + 'Text', + 'Number', + 'Date', + 'Datetime', + 'Enum', + 'Boolean', + ] + const field = this.get(name) || {} + const metadatas = [ + ['key', { handler: 'BlurInput', label: translate('Field Name') }], + [ + 'type', + { + handler: 'Select', + selectOptions: FIELD_TYPES, + label: translate('Field Type'), + }, + ], + ] + const form = new Form(field, metadatas) + + const [container, { body, addFilter }] = Utils.loadTemplateWithRefs(` +
            +

            ${translate('Manage field')}

            +
            + +
            + `) + body.appendChild(form.build()) + if (this.parent.filters) { + addFilter.addEventListener('click', () => { + this.dialog.accept() + this.parent.filters.filterForm(field.key) + }) + addFilter.hidden = false + } + + return this.dialog.open({ template: container }).then(() => { + if (!this.validateName(field.key, field.key !== name)) { + this.pull() + return + } + this.parent.sync.startBatch() + const oldFields = Utils.CopyJSON(this.parent.properties.fields) + if (!name) { + this.add(field) + } else if (name !== field.key) { + this.clear() + // Keep order on rename + for (const old of oldFields) { + if (old.key === name) { + this.add(field) + } else { + this.add(old) + } + } + this.parent.renameField(name, field.key) + } else { + this.push() + } + this.parent.sync.update( + 'properties.fields', + this.parent.properties.fields, + oldFields + ) + this.parent.sync.commitBatch() + }) + } + + validateName(name, isNew = false) { + if (!name) { + Alert.error(translate('Name cannot be empty.')) + return false + } + if (name.includes('.')) { + Alert.error(translate('Name “{name}” should not contain a dot.', { name })) + return false + } + if (isNew && this.has(name)) { + Alert.error(translate('This name already exists: “{name}”', { name })) + return false + } + return true + } + + async confirmDelete(name) { + return this.dialog + .confirm(translate('Are you sure you want to delete this field on all the data?')) + .then(() => { + this.parent.sync.startBatch() + const oldFields = Utils.CopyJSON(this.parent.properties.fields) + this.delete(name) + this.push() + if (this.parent.filters.has(name)) { + this.parent.filters.remove(name) + } + this.parent.deleteField(name) + this.parent.sync.update( + 'properties.fields', + this.parent.properties.fields, + oldFields + ) + this.parent.sync.commitBatch() + }) + } +} diff --git a/umap/static/umap/js/modules/permissions.js b/umap/static/umap/js/modules/permissions.js index 3e8a2ca95..d7b742fc7 100644 --- a/umap/static/umap/js/modules/permissions.js +++ b/umap/static/umap/js/modules/permissions.js @@ -46,6 +46,14 @@ export class MapPermissions { return !this._umap.properties.permissions?.owner } + isDraft() { + return this.properties.share_status === 0 + } + + userIsAuth() { + return Boolean(this._umap.properties.user?.id) + } + _editAnonymous(container) { const fields = [] if (this.isOwner()) { @@ -77,7 +85,7 @@ export class MapPermissions { ) } - if (this._umap.properties.user?.id) { + if (this.userIsAuth()) { // We have a user, and this user has come through here, so they can edit the map, so let's allow to own the map. // Note: real check is made on the back office anyway. const advancedActions = DomUtil.createFieldset( @@ -247,9 +255,6 @@ export class MapPermissions { } } - isDraft() { - return this.properties.share_status === 0 - } } export class DataLayerPermissions { diff --git a/umap/static/umap/js/modules/rendering/layers/classified.js b/umap/static/umap/js/modules/rendering/layers/classified.js index 8c8d460ad..b562677af 100644 --- a/umap/static/umap/js/modules/rendering/layers/classified.js +++ b/umap/static/umap/js/modules/rendering/layers/classified.js @@ -198,7 +198,7 @@ export const Choropleth = FeatureGroup.extend({ 'properties.choropleth.property', { handler: 'Select', - selectOptions: this.datalayer.fieldKeys, + selectOptions: this.datalayer.fields.keys(), label: translate('Choropleth property value'), }, ], @@ -307,7 +307,7 @@ export const Circles = FeatureGroup.extend({ 'properties.circles.property', { handler: 'Select', - selectOptions: this.datalayer.fieldKeys, + selectOptions: this.datalayer.fields.keys(), label: translate('Property name to compute circles'), }, ], @@ -384,7 +384,7 @@ export const Categorized = FeatureGroup.extend({ _getValue: function (feature) { const key = - this.datalayer.properties.categorized.property || this.datalayer.fieldKeys[0] + this.datalayer.properties.categorized.property || this.datalayer.fields.keys()[0] return feature.properties[key] }, @@ -437,7 +437,7 @@ export const Categorized = FeatureGroup.extend({ 'properties.categorized.property', { handler: 'Select', - selectOptions: this.datalayer.fieldKeys, + selectOptions: this.datalayer.fields.keys(), label: translate('Category property'), }, ], diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index 4ce33d65c..0e397cc23 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -265,7 +265,7 @@ export default class Rules { edit(container) { const template = `
            - ${translate('Conditional style rules')} +

            ${translate('Conditional style rules')}

              diff --git a/umap/static/umap/js/modules/schema.js b/umap/static/umap/js/modules/schema.js index f1d2496be..f71081a4c 100644 --- a/umap/static/umap/js/modules/schema.js +++ b/umap/static/umap/js/modules/schema.js @@ -140,12 +140,9 @@ export const SCHEMA = { label: translate('Display the embed control'), default: true, }, - facetKey: { - type: String, + filters: { + type: Object, impacts: ['ui'], - helpEntries: ['facetKey'], - placeholder: translate('Example: key1,key2|Label 2,key3|Label 3|checkbox'), - label: translate('Filters keys'), }, fields: { type: Object, diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index 3fd3fb366..817125c89 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -31,53 +31,72 @@ export default class TableEditor extends WithTemplate { 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) + const parentType = event.target.dataset.fieldParent + if (property) + this.openHeaderMenu( + property, + parentType === 'map' ? this._umap : this.datalayer + ) }) } - openHeaderMenu(property) { + openHeaderMenu(name, parent) { const actions = [] - let filterItem - if (this._umap.facets.has(property)) { - filterItem = { - label: translate('Remove filter for this column'), - action: () => { - this._umap.facets.remove(property) - this._umap.browser.open('filters') - }, - } + let actionLabel + if (parent.filters.has(name)) { + actionLabel = translate('Edit filter for this field') } else { - filterItem = { - label: translate('Add filter for this column'), - action: () => { - this._umap.facets.add(property) - this._umap.browser.open('filters') - }, - } + actionLabel = translate('Add filter for this field') } - actions.push(filterItem) - if (!this.datalayer.isRemoteLayer()) { + actions.push({ + label: actionLabel, + action: () => { + parent.filters.filterForm(name) + this._umap.browser.open('filters') + }, + }) + // Only allow editing fields for map and local datalayer. + if (!parent.isRemoteLayer?.()) { actions.push({ - label: translate('Rename this column'), - action: () => this.renameProperty(property), + label: translate('Edit this field'), + action: () => { + parent.fields.editField(name).then(() => this.open()) + }, }) actions.push({ - label: translate('Delete this column'), - action: () => this.deleteProperty(property), + label: translate('Delete this field'), + action: () => { + parent.fields.confirmDelete(name).then(() => this.open()) + }, }) } this.contextmenu.open(event, actions) } + get fields() { + return [ + ...this.datalayer.fields.all().map((field) => { + const copy = { ...field } + copy.parent = 'datalayer' + return copy + }), + ...this._umap.fields.all().map((field) => { + const copy = { ...field } + copy.parent = 'map' + return copy + }), + ] + } + renderHeaders() { this.elements.header.innerHTML = '' const th = loadTemplate('') const checkbox = th.firstChild this.elements.header.appendChild(th) - for (const field of this.datalayer.fields) { + for (const field of this.fields) { this.elements.header.appendChild( loadTemplate( - `${field.key}` + `${field.key}` ) ) } @@ -94,7 +113,7 @@ export default class TableEditor extends WithTemplate { this.datalayer.features.forEach((feature) => { if (feature.isFiltered()) return if (inBbox && !feature.isOnScreen(bounds)) return - const tds = this.datalayer.fields.map( + const tds = this.fields.map( (field) => `${feature.properties[field.key] ?? ''}` ) @@ -103,16 +122,8 @@ export default class TableEditor extends WithTemplate { this.elements.body.innerHTML = html } - renameProperty(property) { - this.datalayer.askForRenameProperty(property).then(() => this.open()) - } - - deleteProperty(property) { - this.datalayer.confirmDeleteProperty(property).then(() => this.open()) - } - - addProperty() { - this.datalayer.addProperty().then(() => this.open()) + addField() { + this.datalayer.fields.editField().then(() => this.open()) } open() { @@ -127,7 +138,7 @@ export default class TableEditor extends WithTemplate { `) - addButton.addEventListener('click', () => this.addProperty()) + addButton.addEventListener('click', () => this.addField()) actions.push(addButton) const deleteButton = loadTemplate(` diff --git a/umap/static/umap/js/modules/templates.js b/umap/static/umap/js/modules/templates.js index 0f2e50e7e..600530d6d 100644 --- a/umap/static/umap/js/modules/templates.js +++ b/umap/static/umap/js/modules/templates.js @@ -32,9 +32,8 @@ export default class TemplateImporter { const [root, { tabs, form, body, mine, confirm, confirmData }] = Utils.loadTemplateWithRefs(TEMPLATE) const uri = this.umap.urls.get('template_list') - const userIsAuth = Boolean(this.umap.properties.user?.id) - const defaultTab = userIsAuth ? 'mine' : 'staff' - mine.hidden = !userIsAuth + const defaultTab = this.umap.permissions.userIsAuth() ? 'mine' : 'staff' + mine.hidden = !this.umap.permissions.userIsAuth() const loadTemplates = async (source) => { const [data, response, error] = await this.umap.server.get( diff --git a/umap/static/umap/js/modules/ui/bar.js b/umap/static/umap/js/modules/ui/bar.js index decba1fb1..136c3dced 100644 --- a/umap/static/umap/js/modules/ui/bar.js +++ b/umap/static/umap/js/modules/ui/bar.js @@ -98,7 +98,7 @@ export class TopBar extends WithTemplate { this.elements.shareAnonymous.hidden = false } this.elements.user.addEventListener('click', () => { - if (this._umap.properties.user?.id) { + if (this._umap.permissions.userIsAuth()) { const actions = [ { label: translate('New map'), @@ -249,7 +249,7 @@ export class BottomBar extends WithTemplate { const showMenus = this._umap.getProperty('captionMenus') this.elements.caption.hidden = !showMenus this.elements.browse.hidden = !showMenus - this.elements.filter.hidden = !showMenus || !this._umap.properties.facetKey + this.elements.filter.hidden = !showMenus || !this._umap.filters.size this.buildDataLayerSwitcher() } diff --git a/umap/static/umap/js/modules/ui/dialog.js b/umap/static/umap/js/modules/ui/dialog.js index e3063e553..11aaba4ce 100644 --- a/umap/static/umap/js/modules/ui/dialog.js +++ b/umap/static/umap/js/modules/ui/dialog.js @@ -78,8 +78,7 @@ export default class Dialog extends WithTemplate { if (!this.dialogSupported) { this.elements.form.addEventListener('submit', (event) => { event.preventDefault() - this.dialog.returnValue = 'accept' - this.close() + this.accept() }) } this.dialog.addEventListener('keydown', (e) => { @@ -118,7 +117,6 @@ export default class Dialog extends WithTemplate { this.elements.cancel.hidden = !dialog.cancel this.elements.message.textContent = dialog.message this.elements.message.hidden = !dialog.message - this.elements.target = dialog.target || '' this.elements.template.innerHTML = '' if (dialog.template?.nodeType === 1) { this.elements.template.appendChild(dialog.template) @@ -137,12 +135,13 @@ export default class Dialog extends WithTemplate { if (currentZIndex) { this.dialog.style.zIndex = currentZIndex + 1 } - - this.toggle(true) - + if (this.dialogSupported) { + this.dialog.show() + } else { + this.dialog.hidden = false + } if (this.hasFormData) this.focusable[0].focus() else this.elements.accept.focus() - return this.waitForUser() } @@ -151,37 +150,40 @@ export default class Dialog extends WithTemplate { } close() { - this.toggle(false) - this.dialog.returnValue = undefined - } - - toggle(open = false) { + this._closing = true if (this.dialogSupported) { - if (open) this.dialog.show() - else this.dialog.close() + this.dialog.close() } else { - this.dialog.hidden = !open - if (this.elements.target && !open) { - this.elements.target.focus() - } - if (!open) { - this.dialog.dispatchEvent(new CustomEvent('close')) - } + this.dialog.hidden = true + this.dialog.dispatchEvent(new CustomEvent('close')) } } + accept() { + this.dialog.returnValue = 'accept' + this.close() + } + waitForUser() { return new Promise((resolve) => { - this.dialog.addEventListener( - 'close', - (event) => { - if (this.dialog.returnValue === 'accept') { - const value = this.hasFormData ? this.collectFormData() : true - resolve(value) - } - }, - { once: true } - ) + const onClose = () => { + this._closing = false + if (this.dialog.returnValue === 'accept') { + const value = this.hasFormData ? this.collectFormData() : true + resolve(value) + } + } + const waitForClose = () => { + this.dialog.returnValue = undefined + this.dialog.addEventListener('close', () => onClose(), { once: true }) + } + if (this._closing) { + // We are opening a new dialog while another is not fully closed, + // so let's first wait for that one to be fully closed + this.dialog.addEventListener('close', () => waitForClose(), { once: true }) + } else { + waitForClose() + } }) } diff --git a/umap/static/umap/js/modules/umap.js b/umap/static/umap/js/modules/umap.js index e62903e9a..17e528961 100644 --- a/umap/static/umap/js/modules/umap.js +++ b/umap/static/umap/js/modules/umap.js @@ -10,7 +10,7 @@ import { import Browser from './browser.js' import Caption from './caption.js' import { DataLayer } from './data/layer.js' -import Facets from './facets.js' +import Filters from './filters.js' import { MutatingForm } from './form/builder.js' import { Formatter } from './formatter.js' import Help from './help.js' @@ -33,7 +33,7 @@ import Tooltip from './ui/tooltip.js' import URLs from './urls.js' import * as Utils from './utils.js' import * as DOMUtils from './domutils.js' -import { DataLayerManager } from './managers.js' +import { DataLayerManager, FieldManager } from './managers.js' import { Importer as OpenRouteService } from './importers/openrouteservice.js' export default class Umap { @@ -132,7 +132,8 @@ export default class Umap { this.contextmenu = new ContextMenu() this.server = new ServerRequest() this.request = new Request() - this.facets = new Facets(this) + this.fields = new FieldManager(this, this.dialog) + this.filters = new Filters(this, this) this.browser = new Browser(this, this._leafletMap) this.caption = new Caption(this, this._leafletMap) this.importer = new Importer(this) @@ -161,10 +162,6 @@ export default class Umap { ) { this.properties.slideshow.active = true } - if (this.properties.advancedFilterKey) { - this.properties.facetKey = this.properties.advancedFilterKey - delete this.properties.advancedFilterKey - } // Global storage for retrieving datalayers and features. this.datalayers = new DataLayerManager() @@ -262,15 +259,13 @@ export default class Umap { return window.self !== window.top } - get fields() { - if (!this.properties.fields) this.properties.fields = [] - return this.properties.fields - } - get fieldKeys() { - return this.fields - .map((field) => field.key) - .concat(...this.datalayers.active().map((dl) => dl.fieldKeys)) + return Array.from( + new Set([ + ...this.fields.keys(), + ...this.datalayers.active().reduce((acc, dl) => acc.concat(dl.fieldKeys), []), + ]) + ) } setPropertiesFromQueryString() { @@ -425,7 +420,7 @@ export default class Umap { action: () => this.openBrowser('data'), } ) - if (this.properties.facetKey) { + if (this.filters.size) { items.push({ label: translate('Filter data'), action: () => this.openBrowser('filters'), @@ -842,6 +837,8 @@ export default class Umap { 'properties.captionBar', 'properties.captionMenus', 'properties.layerSwitcher', + 'properties.zoomTo', + 'properties.easing', ]) const builder = new MutatingForm(this, UIFields, { umap: this }) const controlsOptions = DomUtil.createFieldset( @@ -875,23 +872,42 @@ export default class Umap { defaultShapeProperties.appendChild(builder.build()) } - _editDefaultProperties(container) { + _editFieldsAndKeys(parent) { + const body = Utils.loadTemplate(` +
              +

              ${translate('Fields, filters and keys')}

              +
              +
              +
              + `) + parent.appendChild(body) + + const fieldsContainer = Utils.loadTemplate(` +
              + ${translate('Manage Fields')} +
              + `) + + body.appendChild(fieldsContainer) + this.fields.edit(fieldsContainer) + const template = ` +
              + ${translate('Keys management')} +
              +
              + ` + const [root, { keyContainer }] = Utils.loadTemplateWithRefs(template) const optionsFields = [ - 'properties.zoomTo', - 'properties.easing', 'properties.labelKey', 'properties.sortKey', 'properties.filterKey', - 'properties.facetKey', 'properties.slugKey', ] + body.appendChild(root) const builder = new MutatingForm(this, optionsFields, { umap: this }) - const defaultProperties = DomUtil.createFieldset( - container, - translate('Default properties') - ) - defaultProperties.appendChild(builder.build()) + keyContainer.appendChild(builder.build()) + this.filters.edit(body) } _editInteractionsProperties(container) { @@ -1166,7 +1182,7 @@ export default class Umap { ) this._editControls(container) this._editShapeProperties(container) - this._editDefaultProperties(container) + this._editFieldsAndKeys(container) this._editInteractionsProperties(container) this.rules.edit(container) this._editTilelayer(container) @@ -1258,6 +1274,18 @@ export default class Umap { return properties } + renameField(oldName, newName) { + for (const datalayer of this.datalayers.active()) { + datalayer.renameFeaturesField(oldName, newName) + } + } + + deleteField(name) { + for (const datalayer of this.datalayers.active()) { + datalayer.deleteFeaturesField(name) + } + } + geometry() { /* Return a GeoJSON geometry Object */ const latlng = this._leafletMap.latLng( @@ -1276,18 +1304,51 @@ export default class Umap { this.drop.enable() this.fire('edit:enabled') this.initSyncEngine() - this.datalayers.active().forEach((datalayer) => { - if (!datalayer.isReadOnly() && datalayer._found_duplicate_id) { - datalayer._found_duplicate_id = false + this.checkForLegacy() + this.checkForAnonymous() + } + + checkForAnonymous() { + if ( + this.permissions.isAnonymousMap() && + this.permissions.isOwner() && + this.permissions.userIsAuth() + ) { + this.dialog + .confirm( + translate('This map is anonymous, do you want to attach it to your account?') + ) + .then(() => { + this.permissions.attach() + }) + } + } + + checkForLegacy() { + let needSaveAlert = false + if (this._migrated) { + needSaveAlert = true + delete this._migrated + // Force user to save + this.sync.update('properties.name', this.properties.name, this.properties.name) + } + for (const datalayer of this.datalayers.active()) { + if (!datalayer.isReadOnly() && datalayer._migrated) { + datalayer._migrated = false // Force user to resave those datalayers datalayer.sync.update( 'properties.name', datalayer.properties.name, datalayer.properties.name ) - Alert.info(translate('Layer has been migrated, please save the map.')) + needSaveAlert = true } - }) + } + if (needSaveAlert) { + Alert.warning( + translate('The map has been upgraded to latest version, please save it.') + ) + } } disableEdit() { @@ -1331,6 +1392,12 @@ export default class Umap { // Propagate will remove the fields it has already // processed fields = this.propagate(fields) + if (fields.includes('properties.filters')) { + this.filters.load() + if (this.browser.isOpen()) { + this.browser.buildFilters() + } + } const impacts = Utils.getImpactsFromSchema(fields) for (const impact of impacts) { @@ -1383,7 +1450,7 @@ export default class Umap { }, user: () => { Utils.eachElement('.umap-user .username', (el) => { - if (this.properties.user?.id) { + if (this.permissions.userIsAuth()) { el.textContent = this.properties.user.name } }) diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js index 37224cec7..c93b65c00 100644 --- a/umap/static/umap/js/modules/utils.js +++ b/umap/static/umap/js/modules/utils.js @@ -400,8 +400,30 @@ export function template(str, data) { }) } +const DATE_REGEX = [ + // Format 1: "YYYY-MM-DD" + /^(?\d{4})[\-\/](?\d{2})[\-\/](?\d{2})$/, + // Format2 : "DD-MM-YYYY" + /^(?0[1-9]|[12][0-9]|3[01])[\-\/](?0[1-9]|1[0-2])[\-\/](?\d{4})/, +] + export function parseNaiveDate(value) { - const naive = new Date(value) + let naive + if (!value) return undefined + value = String(value) + for (const regex of DATE_REGEX) { + const parsed = value.match(regex) + if (parsed) { + const { year, month, day } = parsed.groups + naive = new Date(year, Number.parseInt(month, 10) - 1, Number.parseInt(day, 10)) + break + } + } + if (!naive) { + naive = new Date(value) + } + // Number.isNaN will always return false for invalid date + if (isNaN(naive)) return undefined // Let's pretend naive date are UTC, and remove time… return new Date(Date.UTC(naive.getFullYear(), naive.getMonth(), naive.getDate())) } diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index 840942dd2..5eaf6ba09 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -15,7 +15,7 @@ L.DomUtil.createFieldset = (container, legend, options) => { const details = L.DomUtil.create('details', options.className || '', container) const summary = L.DomUtil.add('summary', '', details) if (options.icon) L.DomUtil.createIcon(summary, options.icon) - L.DomUtil.add('span', '', summary, legend) + L.DomUtil.add('h4', '', summary, legend) const fieldset = L.DomUtil.add('fieldset', '', details) details.open = options.on === true if (options.callback) { diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css index 71bfc4f87..8a1b397fb 100644 --- a/umap/static/umap/map.css +++ b/umap/static/umap/map.css @@ -541,7 +541,7 @@ ul.photon-autocomplete { /* ********************************* */ /* Help Lightbox */ /* ********************************* */ -.umap-help-button { +.umap-help-button[type="button"] { display: inline-block; width: 16px; height: 16px; diff --git a/umap/static/umap/unittests/utils.js b/umap/static/umap/unittests/utils.js index e3681f2fa..0216d31eb 100644 --- a/umap/static/umap/unittests/utils.js +++ b/umap/static/umap/unittests/utils.js @@ -838,6 +838,24 @@ describe('Utils', () => { '2024-03-03T00:00:00.000Z' ) }) + it('should parse a French date', () => { + assert.equal( + Utils.parseNaiveDate('14/09/2020').toISOString(), + '2020-09-14T00:00:00.000Z' + ) + }) + it('should parse a French date with ambiguous day number', () => { + assert.equal( + Utils.parseNaiveDate('08/09/2020').toISOString(), + '2020-09-08T00:00:00.000Z' + ) + }) + it('should parse a US date', () => { + assert.equal( + Utils.parseNaiveDate('1/14/2020').toISOString(), + '2020-01-14T00:00:00.000Z' + ) + }) }) describe('#isObject', () => { diff --git a/umap/static/umap/vars.css b/umap/static/umap/vars.css index 0a2604b27..d36744e90 100644 --- a/umap/static/umap/vars.css +++ b/umap/static/umap/vars.css @@ -16,6 +16,7 @@ --color-darkCyan: #009099; --color-veryDarkCyan: #046460; --color-red: #c60f13; + --color-darkOrange: #b36200; --color-darkRed: #5b2a2a; --background-color: var(--color-light); diff --git a/umap/templates/umap/css.html b/umap/templates/umap/css.html index 68016d830..64b2fb880 100644 --- a/umap/templates/umap/css.html +++ b/umap/templates/umap/css.html @@ -18,10 +18,10 @@ href="{% static 'umap/vendors/iconlayers/iconLayers.css' %}" /> - + diff --git a/umap/tests/integration/test_anonymous_owned_map.py b/umap/tests/integration/test_anonymous_owned_map.py index 0ab2fc7e8..599af31ce 100644 --- a/umap/tests/integration/test_anonymous_owned_map.py +++ b/umap/tests/integration/test_anonymous_owned_map.py @@ -18,25 +18,24 @@ def owner_session(anonymap, context, live_server): key, value = anonymap.signed_cookie_elements signed = get_cookie_signer(salt=key).sign(value) context.add_cookies([{"name": key, "value": signed, "url": live_server.url}]) - return context.new_page() -def test_map_load_with_owner(anonymap, live_server, owner_session): - owner_session.goto(f"{live_server.url}{anonymap.get_absolute_url()}") - map_el = owner_session.locator("#map") +def test_map_load_with_owner(anonymap, live_server, owner_session, page): + page.goto(f"{live_server.url}{anonymap.get_absolute_url()}") + map_el = page.locator("#map") expect(map_el).to_be_visible() - enable = owner_session.get_by_role("button", name="Edit") + enable = page.get_by_role("button", name="Edit") expect(enable).to_be_visible() enable.click() - disable = owner_session.get_by_role("button", name="View") + disable = page.get_by_role("button", name="View") expect(disable).to_be_visible() - save = owner_session.get_by_role("button", name="Save") + save = page.get_by_role("button", name="Save") expect(save).to_be_visible() - add_marker = owner_session.get_by_title("Draw a marker") + add_marker = page.get_by_title("Draw a marker") expect(add_marker).to_be_visible() - edit_settings = owner_session.get_by_title("Map advanced properties") + edit_settings = page.get_by_title("Map advanced properties") expect(edit_settings).to_be_visible() - edit_permissions = owner_session.get_by_title("Update permissions and editors") + edit_permissions = page.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_visible() @@ -71,35 +70,31 @@ def test_map_load_with_anonymous_but_editable_layer( expect(edit_permissions).to_be_hidden() -def test_owner_permissions_form(map, datalayer, live_server, owner_session): - owner_session.goto(f"{live_server.url}{map.get_absolute_url()}?edit") - edit_permissions = owner_session.get_by_title("Update permissions and editors") +def test_owner_permissions_form(map, datalayer, live_server, owner_session, page): + page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + edit_permissions = page.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_visible() edit_permissions.click() - owner_field = owner_session.locator(".umap-field-owner") + owner_field = page.locator(".umap-field-owner") expect(owner_field).to_be_hidden() - editors_field = owner_session.locator(".umap-field-editors input") + editors_field = page.locator(".umap-field-editors input") expect(editors_field).to_be_hidden() - datalayer_label = owner_session.get_by_text('Who can edit "test datalayer"') + datalayer_label = page.get_by_text('Who can edit "test datalayer"') expect(datalayer_label).to_be_visible() - options = owner_session.locator( - ".datalayer-permissions select[name='edit_status'] option" - ) + options = page.locator(".datalayer-permissions select[name='edit_status'] option") expect(options).to_have_count(3) - option = owner_session.locator( + option = page.locator( ".datalayer-permissions select[name='edit_status'] option:checked" ) expect(option).to_have_text("Inherit") - expect(owner_session.locator(".umap-field-share_status select")).to_be_visible() + expect(page.locator(".umap-field-share_status select")).to_be_visible() options = [ int(option.get_attribute("value")) - for option in owner_session.locator( - ".umap-field-share_status select option" - ).all() + for option in page.locator(".umap-field-share_status select option").all() ] assert options == [Map.DRAFT, Map.PUBLIC] # This field should not be present in anonymous maps - expect(owner_session.locator(".umap-field-owner")).to_be_hidden() + expect(page.locator(".umap-field-owner")).to_be_hidden() def test_anonymous_can_add_marker_on_editable_layer( @@ -230,16 +225,16 @@ def test_alert_message_after_create_show_link_even_without_mail( expect(alert.get_by_role("button", name="Send me the link")).to_be_hidden() -def test_anonymous_owner_can_delete_the_map(anonymap, live_server, owner_session): +def test_anonymous_owner_can_delete_the_map(anonymap, live_server, owner_session, page): assert Map.objects.count() == 1 - owner_session.goto(f"{live_server.url}{anonymap.get_absolute_url()}") - owner_session.get_by_role("button", name="Edit").click() - owner_session.get_by_role("button", name="Map advanced properties").click() - owner_session.get_by_text("Advanced actions").click() - expect(owner_session.get_by_role("button", name="Delete")).to_be_visible() - owner_session.get_by_role("button", name="Delete").click() - with owner_session.expect_response(re.compile(r".*/update/delete/.*")): - owner_session.get_by_role("button", name="OK").click() + page.goto(f"{live_server.url}{anonymap.get_absolute_url()}") + page.get_by_role("button", name="Edit").click() + page.get_by_role("button", name="Map advanced properties").click() + page.get_by_text("Advanced actions").click() + expect(page.get_by_role("button", name="Delete")).to_be_visible() + page.get_by_role("button", name="Delete").click() + with page.expect_response(re.compile(r".*/update/delete/.*")): + page.get_by_role("button", name="OK").click() assert Map.objects.get(pk=anonymap.pk).share_status == Map.DELETED @@ -251,3 +246,28 @@ def test_non_owner_cannot_see_delete_button(anonymap, live_server, page): page.get_by_role("button", name="Map advanced properties").click() page.get_by_text("Advanced actions").click() expect(page.get_by_role("button", name="Delete")).to_be_hidden() + + +def test_logged_in_user_should_have_a_message_to_attach_map( + anonymap, live_server, login, user, page, owner_session +): + page.goto(f"{live_server.url}{anonymap.get_absolute_url()}") + page.get_by_role("button", name="Edit").click() + expect( + page.get_by_text( + "This map is anonymous, do you want to attach it to your account?" + ) + ).to_be_hidden() + + page = login(user) + page.goto(f"{live_server.url}{anonymap.get_absolute_url()}") + page.get_by_role("button", name="Edit").click() + expect( + page.get_by_text( + "This map is anonymous, do you want to attach it to your account?" + ) + ).to_be_visible() + with page.expect_response(re.compile(r".*/update/owner/.*")): + page.get_by_role("button", name="OK").click() + saved = Map.objects.get(pk=anonymap.pk) + assert saved.owner diff --git a/umap/tests/integration/test_edit_map.py b/umap/tests/integration/test_edit_map.py index 1fb9aadce..fcf626db3 100644 --- a/umap/tests/integration/test_edit_map.py +++ b/umap/tests/integration/test_edit_map.py @@ -187,7 +187,7 @@ def test_sortkey_impacts_datalayerindex(map, live_server, page): # Change the default sortkey to be "key" page.get_by_role("button", name="Edit").click() page.get_by_role("button", name="Map advanced properties").click() - page.get_by_text("Default properties").click() + page.get_by_text("Fields, filters and keys").click() # Click "define" page.locator(".panel .umap-field-sortKey .define").click() diff --git a/umap/tests/integration/test_edit_marker.py b/umap/tests/integration/test_edit_marker.py index 0e8465afc..99738d60e 100644 --- a/umap/tests/integration/test_edit_marker.py +++ b/umap/tests/integration/test_edit_marker.py @@ -137,6 +137,6 @@ def test_add_property_from_feature_properties_panel( page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") page.locator(".leaflet-marker-icon").click(modifiers=["Shift"]) page.get_by_role("button", name="Add a new field").click() - page.locator('input[name="prompt"]').fill("newprop") + page.locator('input[name="key"]').fill("newprop") page.get_by_role("button", name="OK").click() expect(page.locator(".panel.right").get_by_text("newprop")).to_be_visible() diff --git a/umap/tests/integration/test_facets_browser.py b/umap/tests/integration/test_facets_browser.py deleted file mode 100644 index f708eae44..000000000 --- a/umap/tests/integration/test_facets_browser.py +++ /dev/null @@ -1,279 +0,0 @@ -import copy -import re - -import pytest -from playwright.sync_api import expect - -from ..base import DataLayerFactory - -pytestmark = pytest.mark.django_db - - -DATALAYER_DATA1 = { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": { - "mytype": "even", - "name": "Point 2", - "mynumber": 10, - "mydate": "2024/04/14 12:19:17", - }, - "geometry": {"type": "Point", "coordinates": [0.065918, 48.385442]}, - }, - { - "type": "Feature", - "properties": { - "mytype": "odd", - "name": "Point 1", - "mynumber": 12, - "mydate": "2024/03/13 12:20:20", - }, - "geometry": {"type": "Point", "coordinates": [3.55957, 49.767074]}, - }, - ], - "_umap_options": { - "name": "Calque 1", - }, -} - - -DATALAYER_DATA2 = { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": { - "mytype": "even", - "name": "Point 4", - "mynumber": 10, - "mydate": "2024/08/18 13:14:15", - }, - "geometry": {"type": "Point", "coordinates": [0.856934, 45.290347]}, - }, - { - "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]}, - }, - ], - "_umap_options": { - "name": "Calque 2", - }, -} - - -DATALAYER_DATA3 = { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": {"name": "a polygon"}, - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [2.12, 49.57], - [1.08, 49.02], - [2.51, 47.55], - [3.19, 48.77], - [2.12, 49.57], - ] - ], - }, - }, - ], - "_umap_options": {"name": "Calque 2", "browsable": False}, -} - - -def test_simple_facet_search(live_server, page, map): - map.settings["properties"]["onLoadPanel"] = "datafilters" - map.settings["properties"]["facetKey"] = "mytype|My type,mynumber|My Number|number" - map.settings["properties"]["showLabel"] = True - map.save() - DataLayerFactory(map=map, data=DATALAYER_DATA1) - DataLayerFactory(map=map, data=DATALAYER_DATA2) - DataLayerFactory(map=map, data=DATALAYER_DATA3) - page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") - panel = page.locator(".panel.left.on") - expect(panel).to_have_class(re.compile(".*expanded.*")) - expect(panel.locator(".umap-browser")).to_be_visible() - # From a non browsable datalayer, should not be impacted - paths = page.locator(".leaflet-overlay-pane path") - expect(paths).to_be_visible() - expect(panel).to_be_visible() - # Facet name - expect(page.get_by_text("My type")).to_be_visible() - # Facet values - oven = page.get_by_text("even") - odd = page.get_by_text("odd") - expect(oven).to_be_visible() - expect(odd).to_be_visible() - expect(paths).to_be_visible() - markers = page.locator(".leaflet-marker-icon") - expect(markers).to_have_count(4) - # Tooltips - expect(page.get_by_role("tooltip", name="Point 1")).to_be_visible() - expect(page.get_by_role("tooltip", name="Point 2")).to_be_visible() - expect(page.get_by_role("tooltip", name="Point 3")).to_be_visible() - expect(page.get_by_role("tooltip", name="Point 4")).to_be_visible() - - # Datalist - expect(panel.get_by_text("Point 1")).to_be_visible() - expect(panel.get_by_text("Point 2")).to_be_visible() - expect(panel.get_by_text("Point 3")).to_be_visible() - expect(panel.get_by_text("Point 4")).to_be_visible() - - # Now let's filter - odd.click() - expect(markers).to_have_count(2) - expect(page.get_by_role("tooltip", name="Point 2")).to_be_hidden() - expect(page.get_by_role("tooltip", name="Point 4")).to_be_hidden() - expect(page.get_by_role("tooltip", name="Point 1")).to_be_visible() - expect(page.get_by_role("tooltip", name="Point 3")).to_be_visible() - expect(panel.get_by_text("Point 2")).to_be_hidden() - expect(panel.get_by_text("Point 4")).to_be_hidden() - expect(panel.get_by_text("Point 1")).to_be_visible() - expect(panel.get_by_text("Point 3")).to_be_visible() - expect(paths).to_be_visible - # Now let's filter - odd.click() - expect(markers).to_have_count(4) - expect(paths).to_be_visible() - - # Let's filter using the number facet - expect(page.get_by_text("My Number")).to_be_visible() - expect(page.get_by_label("Min")).to_have_value("10") - expect(page.get_by_label("Max")).to_have_value("14") - page.get_by_label("Min").fill("11") - page.keyboard.press("Tab") # Move out of the input, so the "change" event is sent - expect(markers).to_have_count(2) - expect(paths).to_be_visible() - page.get_by_label("Max").fill("13") - page.keyboard.press("Tab") - expect(markers).to_have_count(1) - - # Now let's combine - page.get_by_label("Min").fill("10") - page.keyboard.press("Tab") - expect(markers).to_have_count(3) - odd.click() - expect(markers).to_have_count(1) - expect(paths).to_be_visible() - - -def test_date_facet_search(live_server, page, map): - map.settings["properties"]["onLoadPanel"] = "datafilters" - map.settings["properties"]["facetKey"] = "mydate|Date filter|date" - map.save() - DataLayerFactory(map=map, data=DATALAYER_DATA1) - DataLayerFactory(map=map, data=DATALAYER_DATA2) - page.goto(f"{live_server.url}{map.get_absolute_url()}#6/47.5/-1.5") - markers = page.locator(".leaflet-marker-icon") - expect(markers).to_have_count(4) - expect(page.get_by_text("Date Filter")).to_be_visible() - expect(page.get_by_label("From")).to_have_value("2024-03-13") - expect(page.get_by_label("Until")).to_have_value("2024-08-18") - page.get_by_label("From").fill("2024-03-14") - expect(markers).to_have_count(3) - page.get_by_label("Until").fill("2024-08-17") - expect(markers).to_have_count(2) - - -def test_choice_with_empty_value(live_server, page, map): - map.settings["properties"]["onLoadPanel"] = "datafilters" - map.settings["properties"]["facetKey"] = "mytype|My type" - map.save() - data = copy.deepcopy(DATALAYER_DATA1) - data["features"][0]["properties"]["mytype"] = "" - del data["features"][1]["properties"]["mytype"] - DataLayerFactory(map=map, data=data) - DataLayerFactory(map=map, data=DATALAYER_DATA2) - page.goto(f"{live_server.url}{map.get_absolute_url()}#6/47.5/-1.5") - expect(page.get_by_text("")).to_be_visible() - markers = page.locator(".leaflet-marker-icon") - expect(markers).to_have_count(4) - page.get_by_text("").click() - expect(markers).to_have_count(2) - - -def test_number_with_zero_value(live_server, page, map): - map.settings["properties"]["onLoadPanel"] = "datafilters" - map.settings["properties"]["facetKey"] = "mynumber|Filter|number" - map.save() - data = copy.deepcopy(DATALAYER_DATA1) - data["features"][0]["properties"]["mynumber"] = 0 - DataLayerFactory(map=map, data=data) - DataLayerFactory(map=map, data=DATALAYER_DATA2) - page.goto(f"{live_server.url}{map.get_absolute_url()}#6/47.5/-1.5") - expect(page.get_by_label("Min")).to_have_value("0") - expect(page.get_by_label("Max")).to_have_value("14") - page.get_by_label("Min").fill("1") - page.keyboard.press("Tab") # Move out of the input, so the "change" event is sent - markers = page.locator(".leaflet-marker-icon") - expect(markers).to_have_count(3) - - -def test_facets_search_are_persistent_when_closing_panel(live_server, page, map): - map.settings["properties"]["onLoadPanel"] = "datafilters" - map.settings["properties"]["facetKey"] = "mytype|My type,mynumber|My Number|number" - map.save() - DataLayerFactory(map=map, data=DATALAYER_DATA1) - DataLayerFactory(map=map, data=DATALAYER_DATA2) - page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") - panel = page.locator(".panel.left") - - # Facet values - odd = page.get_by_label("odd") - markers = page.locator(".leaflet-marker-icon") - expect(markers).to_have_count(4) - - # Datalist in the browser - expect(panel.get_by_text("Point 1")).to_be_visible() - expect(panel.get_by_text("Point 2")).to_be_visible() - expect(panel.get_by_text("Point 3")).to_be_visible() - expect(panel.get_by_text("Point 4")).to_be_visible() - - # Now let's filter - odd.click() - expect(page.locator(".filters summary")).to_have_attribute("data-badge", " ") - expect(page.locator(".umap-control-browse")).to_have_attribute("data-badge", " ") - expect(markers).to_have_count(2) - expect(panel.get_by_text("Point 2")).to_be_hidden() - expect(panel.get_by_text("Point 4")).to_be_hidden() - expect(panel.get_by_text("Point 1")).to_be_visible() - expect(panel.get_by_text("Point 3")).to_be_visible() - - # Let's filter using the number facet - expect(panel.get_by_label("Min")).to_have_value("10") - expect(panel.get_by_label("Max")).to_have_value("14") - page.get_by_label("Min").fill("13") - page.keyboard.press("Tab") # Move out of the input, so the "change" event is sent - expect(panel.get_by_label("Min")).to_have_attribute("data-modified", "true") - expect(markers).to_have_count(1) - expect(panel.get_by_text("Point 2")).to_be_hidden() - expect(panel.get_by_text("Point 4")).to_be_hidden() - expect(panel.get_by_text("Point 1")).to_be_hidden() - expect(panel.get_by_text("Point 3")).to_be_visible() - - # Close panel - expect(panel.locator(".filters summary")).to_have_attribute("data-badge", " ") - expect(page.locator(".umap-control-browse")).to_have_attribute("data-badge", " ") - panel.get_by_role("button", name="Close").click() - page.get_by_role("button", name="Open browser").click() - expect(panel.get_by_label("Min")).to_have_value("13") - expect(panel.get_by_label("Min")).to_have_attribute("data-modified", "true") - expect(panel.get_by_label("odd")).to_be_checked() - - # Datalist in the browser should be inchanged - expect(panel.get_by_text("Point 2")).to_be_hidden() - expect(panel.get_by_text("Point 4")).to_be_hidden() - expect(panel.get_by_text("Point 1")).to_be_hidden() - expect(panel.get_by_text("Point 3")).to_be_visible() diff --git a/umap/tests/integration/test_fields.py b/umap/tests/integration/test_fields.py new file mode 100644 index 000000000..c3eaa062a --- /dev/null +++ b/umap/tests/integration/test_fields.py @@ -0,0 +1,465 @@ +import json +import re +from copy import deepcopy +from pathlib import Path + +import pytest + +from umap.models import DataLayer, Map + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + + +DATALAYER_DATA1 = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "mytype": "even", + "name": "Point 2", + "mynumber": 10, + "mydate": "2024/04/14 12:19:17", + }, + "geometry": {"type": "Point", "coordinates": [0.065918, 48.385442]}, + }, + { + "type": "Feature", + "properties": { + "mytype": "odd", + "name": "Point 1", + "mynumber": 12, + "mydate": "2024/03/13 12:20:20", + }, + "geometry": {"type": "Point", "coordinates": [3.55957, 49.767074]}, + }, + ], + "_umap_options": { + "name": "Calque 1", + }, +} + + +DATALAYER_DATA2 = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "mytype": "even", + "name": "Point 4", + "mynumber": 10, + "mydate": "2024/08/18 13:14:15", + }, + "geometry": {"type": "Point", "coordinates": [0.856934, 45.290347]}, + }, + { + "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]}, + }, + ], + "_umap_options": { + "name": "Calque 2", + }, +} + + +def test_can_add_field_on_map(live_server, page, openmap): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_role("button", name="Map advanced properties").click() + page.get_by_text("Fields, filters and keys").click() + page.get_by_role("button", name="Add a new field").click() + page.get_by_role("textbox", name="Field Name ✔").fill("newfield") + page.get_by_label("Field Type").select_option("Number") + page.get_by_role("button", name="OK").click() + with page.expect_response(re.compile(r".*/update/settings/")): + page.get_by_role("button", name="Save").click() + saved = Map.objects.get(pk=openmap.pk) + assert saved.settings["properties"]["fields"] == [ + {"key": "newfield", "type": "Number"} + ] + + +def test_can_add_field_on_datalayer(live_server, page, openmap, datalayer): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_role("button", name="Manage layers").click() + page.get_by_role("button", name="Edit", exact=True).click() + page.get_by_text("Fields, filters and keys").click() + page.get_by_role("button", name="Add a new field").click() + page.get_by_role("textbox", name="Field Name ✔").fill("newfield") + page.get_by_label("Field Type").select_option("Number") + page.get_by_role("button", name="OK").click() + with page.expect_response(re.compile(r".*/datalayer/update/")): + page.get_by_role("button", name="Save").click() + saved = DataLayer.objects.first() + assert saved.settings["fields"] == [ + {"key": "name", "type": "String"}, + {"key": "description", "type": "Text"}, + {"key": "newfield", "type": "Number"}, + ] + + +def test_edit_and_rename_field_from_datalayer(live_server, page, openmap): + DataLayerFactory(map=openmap, data=DATALAYER_DATA1) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_role("button", name="Manage layers").click() + page.get_by_role("button", name="Edit", exact=True).click() + page.get_by_text("Fields, filters and keys").click() + page.get_by_role("button", name="Edit this field").first.click() + page.get_by_role("textbox", name="Field Name ✔").fill("mytypenew") + page.get_by_label("Field Type").select_option("Text") + page.get_by_role("button", name="OK").click() + with page.expect_response(re.compile(r".*/datalayer/update/")): + page.get_by_role("button", name="Save").click() + saved = DataLayer.objects.first() + assert saved.settings["fields"] == [ + { + "key": "mytypenew", + "type": "Text", + }, + { + "key": "name", + "type": "String", + }, + { + "key": "mynumber", + "type": "String", + }, + { + "key": "mydate", + "type": "String", + }, + ] + data = json.loads(Path(saved.geojson.path).read_text()) + assert data["features"][0]["properties"] == { + "mydate": "2024/03/13 12:20:20", + "mynumber": 12, + "mytypenew": "odd", + "name": "Point 1", + } + assert data["features"][1]["properties"] == { + "mydate": "2024/04/14 12:19:17", + "mynumber": 10, + "mytypenew": "even", + "name": "Point 2", + } + page.locator(".edit-undo").click() + with page.expect_response(re.compile(r".*/datalayer/update/")): + page.get_by_role("button", name="Save").click() + saved = DataLayer.objects.first() + assert saved.settings["fields"] == [ + { + "key": "mytype", + "type": "String", + }, + { + "key": "name", + "type": "String", + }, + { + "key": "mynumber", + "type": "String", + }, + { + "key": "mydate", + "type": "String", + }, + ] + data = json.loads(Path(saved.geojson.path).read_text()) + assert data["features"][0]["properties"] == { + "mydate": "2024/03/13 12:20:20", + "mynumber": 12, + "mytype": "odd", + "name": "Point 1", + } + assert data["features"][1]["properties"] == { + "mydate": "2024/04/14 12:19:17", + "mynumber": 10, + "mytype": "even", + "name": "Point 2", + } + + +def test_edit_and_rename_field_from_map(live_server, page, openmap): + dl1 = DataLayerFactory(map=openmap, data=DATALAYER_DATA1) + DataLayerFactory(map=openmap, data=DATALAYER_DATA2) + openmap.settings["properties"]["fields"] = [ + {"key": "mytype", "type": "String"}, + {"key": "mynumber", "type": "Number"}, + ] + openmap.save() + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_role("button", name="Map advanced properties").click() + page.get_by_text("Fields, filters and keys").click() + page.get_by_role("button", name="Edit this field").first.click() + page.get_by_role("textbox", name="Field Name ✔").fill("mytypenew") + page.get_by_label("Field Type").select_option("Text") + page.get_by_role("button", name="OK").click() + with page.expect_response(re.compile(r".*/datalayer/update/")): + page.get_by_role("button", name="Save").click() + saved = Map.objects.get(pk=openmap.pk) + assert saved.settings["properties"]["fields"] == [ + {"key": "mytypenew", "type": "Text"}, + {"key": "mynumber", "type": "Number"}, + ] + saved = DataLayer.objects.get(pk=dl1.pk) + assert saved.settings["fields"] == [ + { + "key": "name", + "type": "String", + }, + { + "key": "mydate", + "type": "String", + }, + ] + data = json.loads(Path(saved.geojson.path).read_text()) + assert data["features"][0]["properties"] == { + "mydate": "2024/03/13 12:20:20", + "mynumber": 12, + "mytypenew": "odd", + "name": "Point 1", + } + assert data["features"][1]["properties"] == { + "mydate": "2024/04/14 12:19:17", + "mynumber": 10, + "mytypenew": "even", + "name": "Point 2", + } + page.locator(".edit-undo").click() + + with page.expect_response(re.compile(rf".*/datalayer/update/{dl1.pk}")): + with page.expect_response(re.compile(r".*/update/settings/")): + page.get_by_role("button", name="Save").click() + saved = Map.objects.get(pk=openmap.pk) + assert saved.settings["properties"]["fields"] == [ + {"key": "mytype", "type": "String"}, + {"key": "mynumber", "type": "Number"}, + ] + saved = DataLayer.objects.get(pk=dl1.pk) + assert saved.settings["fields"] == [ + { + "key": "name", + "type": "String", + }, + { + "key": "mydate", + "type": "String", + }, + ] + data = json.loads(Path(saved.geojson.path).read_text()) + assert data["features"][0]["properties"] == { + "mydate": "2024/03/13 12:20:20", + "mynumber": 12, + "mytype": "odd", + "name": "Point 1", + } + assert data["features"][1]["properties"] == { + "mydate": "2024/04/14 12:19:17", + "mynumber": 10, + "mytype": "even", + "name": "Point 2", + } + + +def test_delete_field_from_datalayer(live_server, page, openmap): + DataLayerFactory(map=openmap, data=DATALAYER_DATA1) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_role("button", name="Manage layers").click() + page.get_by_role("button", name="Edit", exact=True).click() + page.get_by_text("Fields, filters and keys").click() + page.get_by_role("button", name="Delete this field").first.click() + page.get_by_role("button", name="OK").click() + with page.expect_response(re.compile(r".*/datalayer/update/")): + page.get_by_role("button", name="Save").click() + saved = DataLayer.objects.first() + assert saved.settings["fields"] == [ + { + "key": "name", + "type": "String", + }, + { + "key": "mynumber", + "type": "String", + }, + { + "key": "mydate", + "type": "String", + }, + ] + data = json.loads(Path(saved.geojson.path).read_text()) + assert data["features"][0]["properties"] == { + "mydate": "2024/03/13 12:20:20", + "mynumber": 12, + "name": "Point 1", + } + assert data["features"][1]["properties"] == { + "mydate": "2024/04/14 12:19:17", + "mynumber": 10, + "name": "Point 2", + } + page.locator(".edit-undo").click() + with page.expect_response(re.compile(r".*/datalayer/update/")): + page.get_by_role("button", name="Save").click() + saved = DataLayer.objects.first() + assert saved.settings["fields"] == [ + { + "key": "mytype", + "type": "String", + }, + { + "key": "name", + "type": "String", + }, + { + "key": "mynumber", + "type": "String", + }, + { + "key": "mydate", + "type": "String", + }, + ] + data = json.loads(Path(saved.geojson.path).read_text()) + assert data["features"][0]["properties"] == { + "mydate": "2024/03/13 12:20:20", + "mynumber": 12, + "name": "Point 1", + "mytype": "odd", + } + assert data["features"][1]["properties"] == { + "mydate": "2024/04/14 12:19:17", + "mynumber": 10, + "name": "Point 2", + "mytype": "even", + } + + +def test_delete_field_from_map(live_server, page, openmap): + dl1 = DataLayerFactory(map=openmap, data=DATALAYER_DATA1) + DataLayerFactory(map=openmap, data=DATALAYER_DATA2) + openmap.settings["properties"]["fields"] = [ + {"key": "mytype", "type": "String"}, + {"key": "mynumber", "type": "Number"}, + ] + openmap.save() + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_role("button", name="Map advanced properties").click() + page.get_by_text("Fields, filters and keys").click() + # Delete field mytype + page.get_by_role("button", name="Delete this field").first.click() + page.get_by_role("button", name="OK").click() + with page.expect_response(re.compile(r".*/datalayer/update/")): + page.get_by_role("button", name="Save").click() + saved = Map.objects.get(pk=openmap.pk) + assert saved.settings["properties"]["fields"] == [ + {"key": "mynumber", "type": "Number"}, + ] + saved = DataLayer.objects.get(pk=dl1.pk) + assert saved.settings["fields"] == [ + { + "key": "name", + "type": "String", + }, + { + "key": "mydate", + "type": "String", + }, + ] + data = json.loads(Path(saved.geojson.path).read_text()) + assert data["features"][0]["properties"] == { + "mydate": "2024/03/13 12:20:20", + "mynumber": 12, + "name": "Point 1", + } + assert data["features"][1]["properties"] == { + "mydate": "2024/04/14 12:19:17", + "mynumber": 10, + "name": "Point 2", + } + page.locator(".edit-undo").click() + with page.expect_response(re.compile(r"./update/settings/.*")): + with page.expect_response(re.compile(rf".*/datalayer/update/{dl1.pk}/")): + page.get_by_role("button", name="Save").click() + saved = Map.objects.get(pk=openmap.pk) + assert saved.settings["properties"]["fields"] == [ + {"key": "mytype", "type": "String"}, + {"key": "mynumber", "type": "Number"}, + ] + saved = DataLayer.objects.get(pk=dl1.pk) + assert saved.settings["fields"] == [ + { + "key": "name", + "type": "String", + }, + { + "key": "mydate", + "type": "String", + }, + ] + data = json.loads(Path(saved.geojson.path).read_text()) + assert data["features"][0]["properties"] == { + "mydate": "2024/03/13 12:20:20", + "mynumber": 12, + "name": "Point 1", + "mytype": "odd", + } + assert data["features"][1]["properties"] == { + "mydate": "2024/04/14 12:19:17", + "mynumber": 10, + "name": "Point 2", + "mytype": "even", + } + + +def test_delete_field_from_datalayer_also_in_map(live_server, page, openmap): + # mytype exist both on map and datalayer 1 + # deleting the field in the datalayer should not delete the data, as the field + # is also defined on the map + data = deepcopy(DATALAYER_DATA1) + data["_umap_options"]["fields"] = [ + {"key": "mytype", "type": "String"}, + {"key": "mynumber", "type": "Number"}, + {"key": "name", "type": "String"}, + {"key": "mydate", "type": "Date"}, + ] + DataLayerFactory(map=openmap, data=data) + openmap.settings["properties"]["fields"] = [ + {"key": "mytype", "type": "String"}, + ] + openmap.save() + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_role("button", name="Manage layers").click() + page.get_by_role("button", name="Edit", exact=True).first.click() + page.get_by_text("Fields, filters and keys").click() + page.get_by_role("button", name="Delete this field").first.click() + page.get_by_role("button", name="OK").click() + with page.expect_response(re.compile(r".*/datalayer/update/")): + page.get_by_role("button", name="Save").click() + saved = DataLayer.objects.first() + assert saved.settings["fields"] == [ + {"key": "mynumber", "type": "Number"}, + {"key": "name", "type": "String"}, + {"key": "mydate", "type": "Date"}, + ] + data = json.loads(Path(saved.geojson.path).read_text()) + assert data["features"][0]["properties"] == { + "mydate": "2024/03/13 12:20:20", + "mynumber": 12, + "name": "Point 1", + "mytype": "odd", + } + assert data["features"][1]["properties"] == { + "mydate": "2024/04/14 12:19:17", + "mynumber": 10, + "name": "Point 2", + "mytype": "even", + } diff --git a/umap/tests/integration/test_filters.py b/umap/tests/integration/test_filters.py new file mode 100644 index 000000000..33c8cf065 --- /dev/null +++ b/umap/tests/integration/test_filters.py @@ -0,0 +1,552 @@ +import copy +import re + +import pytest +from playwright.sync_api import expect + +from umap.models import DataLayer, Map + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + + +DATALAYER_DATA1 = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "mytype": "even", + "name": "Point 2", + "mynumber": 10, + "mydate": "2024/04/14 12:19:17", + }, + "geometry": {"type": "Point", "coordinates": [0.065918, 48.385442]}, + }, + { + "type": "Feature", + "properties": { + "mytype": "odd", + "name": "Point 1", + "mynumber": 12, + "mydate": "2024/03/13 12:20:20", + }, + "geometry": {"type": "Point", "coordinates": [3.55957, 49.767074]}, + }, + ], + "_umap_options": { + "name": "Calque 1", + }, +} + + +DATALAYER_DATA2 = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "mytype": "even", + "name": "Point 4", + "mynumber": 10, + "mydate": "2024/08/18 13:14:15", + }, + "geometry": {"type": "Point", "coordinates": [0.856934, 45.290347]}, + }, + { + "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]}, + }, + ], + "_umap_options": { + "name": "Calque 2", + }, +} + + +DATALAYER_DATA3 = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {"name": "a polygon"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [2.12, 49.57], + [1.08, 49.02], + [2.51, 47.55], + [3.19, 48.77], + [2.12, 49.57], + ] + ], + }, + }, + ], + "_umap_options": {"name": "Calque 2", "browsable": False}, +} + + +def test_simple_facet_search(live_server, page, map): + map.settings["properties"]["onLoadPanel"] = "datafilters" + map.settings["properties"]["filters"] = { + "mytype": {"label": "My type"}, + "mynumber": {"label": "My number", "widget": "minmax"}, + } + map.settings["properties"]["fields"] = [ + {"key": "mytype", "type": "String"}, + {"key": "mynumber", "type": "Number"}, + ] + map.settings["properties"]["showLabel"] = True + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + DataLayerFactory(map=map, data=DATALAYER_DATA3) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + panel = page.locator(".panel.left.on") + expect(panel).to_have_class(re.compile(".*expanded.*")) + expect(panel.locator(".umap-browser")).to_be_visible() + # From a non browsable datalayer, should not be impacted + paths = page.locator(".leaflet-overlay-pane path") + expect(paths).to_be_visible() + expect(panel).to_be_visible() + # Facet name + expect(page.get_by_text("My type")).to_be_visible() + # Facet values + oven = page.get_by_text("even") + odd = page.get_by_text("odd") + expect(oven).to_be_visible() + expect(odd).to_be_visible() + expect(paths).to_be_visible() + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(4) + # Tooltips + # Sometimes PW founds two tooltips with the same name, but cannot reproduce it. + page.wait_for_timeout(300) + expect(page.get_by_role("tooltip", name="Point 1")).to_be_visible() + expect(page.get_by_role("tooltip", name="Point 2")).to_be_visible() + expect(page.get_by_role("tooltip", name="Point 3")).to_be_visible() + expect(page.get_by_role("tooltip", name="Point 4")).to_be_visible() + + # Datalist + expect(panel.get_by_text("Point 1")).to_be_visible() + expect(panel.get_by_text("Point 2")).to_be_visible() + expect(panel.get_by_text("Point 3")).to_be_visible() + expect(panel.get_by_text("Point 4")).to_be_visible() + + # Now let's filter + odd.click() + expect(markers).to_have_count(2) + expect(page.get_by_role("tooltip", name="Point 2")).to_be_hidden() + expect(page.get_by_role("tooltip", name="Point 4")).to_be_hidden() + expect(page.get_by_role("tooltip", name="Point 1")).to_be_visible() + expect(page.get_by_role("tooltip", name="Point 3")).to_be_visible() + expect(panel.get_by_text("Point 2")).to_be_hidden() + expect(panel.get_by_text("Point 4")).to_be_hidden() + expect(panel.get_by_text("Point 1")).to_be_visible() + expect(panel.get_by_text("Point 3")).to_be_visible() + expect(paths).to_be_visible + # Now let's filter + odd.click() + expect(markers).to_have_count(4) + expect(paths).to_be_visible() + + # Let's filter using the number facet + expect(page.get_by_text("My Number")).to_be_visible() + expect(page.get_by_label("Min")).to_have_value("10") + expect(page.get_by_label("Max")).to_have_value("14") + page.get_by_label("Min").fill("11") + page.keyboard.press("Tab") # Move out of the input, so the "change" event is sent + expect(markers).to_have_count(2) + expect(paths).to_be_visible() + page.get_by_label("Max").fill("13") + page.keyboard.press("Tab") + expect(markers).to_have_count(1) + + # Now let's combine + page.get_by_label("Min").fill("10") + page.keyboard.press("Tab") + expect(markers).to_have_count(3) + odd.click() + expect(markers).to_have_count(1) + expect(paths).to_be_visible() + + +def test_date_facet_search(live_server, page, map): + map.settings["properties"]["onLoadPanel"] = "datafilters" + map.settings["properties"]["filters"] = { + "mydate": {"label": "Date filter", "widget": "minmax"} + } + map.settings["properties"]["fields"] = [{"key": "mydate", "type": "Date"}] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/47.5/-1.5") + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(4) + expect(page.get_by_text("Date Filter")).to_be_visible() + expect(page.get_by_label("From")).to_have_value("2024-03-13") + expect(page.get_by_label("Until")).to_have_value("2024-08-18") + page.get_by_label("From").fill("2024-03-14") + expect(markers).to_have_count(3) + page.get_by_label("Until").fill("2024-08-17") + expect(markers).to_have_count(2) + + +def test_choice_with_empty_value(live_server, page, map): + map.settings["properties"]["onLoadPanel"] = "datafilters" + map.settings["properties"]["fields"] = [{"key": "mytype", "type": "String"}] + map.settings["properties"]["filters"] = {"mytype": {"label": "My type"}} + map.save() + data = copy.deepcopy(DATALAYER_DATA1) + data["features"][0]["properties"]["mytype"] = "" + del data["features"][1]["properties"]["mytype"] + DataLayerFactory(map=map, data=data) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/47.5/-1.5") + expect(page.get_by_text("")).to_be_visible() + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(4) + page.get_by_text("").click() + expect(markers).to_have_count(2) + + +def test_number_with_zero_value(live_server, page, map): + map.settings["properties"]["onLoadPanel"] = "datafilters" + map.settings["properties"]["filters"] = { + "mynumber": {"label": "Filter", "widget": "minmax"} + } + map.settings["properties"]["fields"] = [{"key": "mynumber", "type": "Number"}] + map.save() + data = copy.deepcopy(DATALAYER_DATA1) + data["features"][0]["properties"]["mynumber"] = 0 + DataLayerFactory(map=map, data=data) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/47.5/-1.5") + expect(page.get_by_label("Min")).to_have_value("0") + expect(page.get_by_label("Max")).to_have_value("14") + page.get_by_label("Min").fill("1") + page.keyboard.press("Tab") # Move out of the input, so the "change" event is sent + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(3) + + +def test_facets_search_are_persistent_when_closing_panel(live_server, page, map): + map.settings["properties"]["onLoadPanel"] = "datafilters" + map.settings["properties"]["filters"] = { + "mytype": {"label": "My type"}, + "mynumber": {"label": "My Number", "widget": "minmax"}, + } + map.settings["properties"]["fields"] = [ + {"key": "mytype"}, + {"key": "mynumber", "type": "Number"}, + ] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + panel = page.locator(".panel.left") + + # Facet values + odd = page.get_by_label("odd") + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(4) + + # Datalist in the browser + expect(panel.get_by_text("Point 1")).to_be_visible() + expect(panel.get_by_text("Point 2")).to_be_visible() + expect(panel.get_by_text("Point 3")).to_be_visible() + expect(panel.get_by_text("Point 4")).to_be_visible() + + # Now let's filter + odd.click() + expect(page.locator(".filters summary")).to_have_attribute("data-badge", " ") + expect(page.locator(".umap-control-browse")).to_have_attribute("data-badge", " ") + expect(markers).to_have_count(2) + expect(panel.get_by_text("Point 2")).to_be_hidden() + expect(panel.get_by_text("Point 4")).to_be_hidden() + expect(panel.get_by_text("Point 1")).to_be_visible() + expect(panel.get_by_text("Point 3")).to_be_visible() + + # Let's filter using the number facet + expect(panel.get_by_label("Min")).to_have_value("10") + expect(panel.get_by_label("Max")).to_have_value("14") + page.get_by_label("Min").fill("13") + page.keyboard.press("Tab") # Move out of the input, so the "change" event is sent + expect(panel.get_by_label("Min")).to_have_attribute("data-modified", "true") + expect(markers).to_have_count(1) + expect(panel.get_by_text("Point 2")).to_be_hidden() + expect(panel.get_by_text("Point 4")).to_be_hidden() + expect(panel.get_by_text("Point 1")).to_be_hidden() + expect(panel.get_by_text("Point 3")).to_be_visible() + + # Close panel + expect(panel.locator(".filters summary")).to_have_attribute("data-badge", " ") + expect(page.locator(".umap-control-browse")).to_have_attribute("data-badge", " ") + panel.get_by_role("button", name="Close").click() + page.get_by_role("button", name="Open browser").click() + expect(panel.get_by_label("Min")).to_have_value("13") + expect(panel.get_by_label("Min")).to_have_attribute("data-modified", "true") + expect(panel.get_by_label("odd")).to_be_checked() + + # Datalist in the browser should be inchanged + expect(panel.get_by_text("Point 2")).to_be_hidden() + expect(panel.get_by_text("Point 4")).to_be_hidden() + expect(panel.get_by_text("Point 1")).to_be_hidden() + expect(panel.get_by_text("Point 3")).to_be_visible() + + +def test_can_load_legacy_facetKey(live_server, page, openmap): + openmap.settings["properties"]["facetKey"] = ( + "mytype|My Type|radio,mynumber|My Number|number,mydate|My Date|date" + ) + openmap.save() + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + expect( + page.get_by_text("The map has been upgraded to latest version, please save it.") + ).to_be_visible() + with page.expect_response(re.compile("./update/settings/.*")): + page.get_by_role("button", name="Save", exact=True).click() + saved = Map.objects.first() + assert "facetKey" not in saved.settings["properties"] + assert saved.settings["properties"]["filters"] == { + "mydate": { + "label": "My Date", + "widget": "minmax", + }, + "mynumber": { + "label": "My Number", + "widget": "minmax", + }, + "mytype": { + "label": "My Type", + "widget": "radio", + }, + } + assert saved.settings["properties"]["fields"] == [ + {"key": "mytype", "type": "String"}, + {"key": "mynumber", "type": "Number"}, + {"key": "mydate", "type": "Date"}, + ] + + +def test_deleting_field_should_delete_filter(live_server, page, openmap, datalayer): + datalayer.settings["fields"] = [ + {"key": "name", "type": "String"}, + {"key": "foobar", "type": "Number"}, + {"key": "description", "type": "Text"}, + ] + datalayer.settings["filters"] = { + "foobar": {"widget": "minmax", "label": "Foo Bar"}, + "name": {"widget": "checkbox", "label": "Bar Foo"}, + } + datalayer.save() + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_role("button", name="Manage layers").click() + page.get_by_role("button", name="Edit", exact=True).click() + page.get_by_text("Fields, filters and keys").click() + page.get_by_role("button", name="Delete this field").nth(1).click() + page.get_by_role("button", name="OK").click() + with page.expect_response(re.compile(r".*/datalayer/update/")): + page.get_by_role("button", name="Save").click() + saved = DataLayer.objects.first() + assert saved.settings["fields"] == [ + {"key": "name", "type": "String"}, + {"key": "description", "type": "Text"}, + ] + saved.settings["filters"] == { + "name": {"widget": "checkbox", "label": "Bar Foo"}, + } + page.locator(".edit-undo").click() + with page.expect_response(re.compile(r".*/datalayer/update/")): + page.get_by_role("button", name="Save").click() + saved = DataLayer.objects.first() + assert saved.settings["fields"] == [ + {"key": "name", "type": "String"}, + {"key": "foobar", "type": "Number"}, + {"key": "description", "type": "Text"}, + ] + saved.settings["filters"] == { + "foobar": {"widget": "minmax", "label": "Foo Bar"}, + "name": {"widget": "checkbox", "label": "Bar Foo"}, + } + + +def test_deleting_field_from_map_should_delete_filter(live_server, page, openmap): + openmap.settings["properties"]["fields"] = [ + {"key": "name", "type": "String"}, + {"key": "foobar", "type": "Number"}, + {"key": "description", "type": "Text"}, + ] + openmap.settings["properties"]["filters"] = { + "foobar": {"widget": "minmax", "label": "Foo Bar"}, + "name": {"widget": "checkbox", "label": "Bar Foo"}, + } + openmap.save() + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_role("button", name="Map advanced properties").click() + page.get_by_text("Fields, filters and keys").click() + page.get_by_role("button", name="Delete this field").nth(1).click() + page.get_by_role("button", name="OK").click() + with page.expect_response(re.compile(r"./update/settings/.*")): + page.get_by_role("button", name="Save").click() + saved = Map.objects.first() + assert saved.settings["properties"]["fields"] == [ + {"key": "name", "type": "String"}, + {"key": "description", "type": "Text"}, + ] + saved.settings["properties"]["filters"] == { + "name": {"widget": "checkbox", "label": "Bar Foo"}, + } + page.locator(".edit-undo").click() + with page.expect_response(re.compile(r"./update/settings/.*")): + page.get_by_role("button", name="Save").click() + saved = Map.objects.first() + assert saved.settings["properties"]["fields"] == [ + {"key": "name", "type": "String"}, + {"key": "foobar", "type": "Number"}, + {"key": "description", "type": "Text"}, + ] + saved.settings["properties"]["filters"] == { + "foobar": {"widget": "minmax", "label": "Foo Bar"}, + "name": {"widget": "checkbox", "label": "Bar Foo"}, + } + + +def test_can_create_filter_from_new_field(live_server, page, openmap): + openmap.settings["properties"]["onLoadPanel"] = "datafilters" + openmap.save() + DataLayerFactory(map=openmap, data=DATALAYER_DATA1) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_role("button", name="Manage layers").click() + page.get_by_role("button", name="Edit", exact=True).nth(1).click() + page.get_by_role("heading", name="Fields, filters and keys").click() + page.get_by_role("button", name="Add a new field").click() + page.get_by_role("textbox", name="Field Name ✔").fill("foobar") + page.get_by_role("button", name="Add filter for this field").click() + page.get_by_role("textbox", name="Human readable name of the").fill("Foo Bar") + page.wait_for_timeout(300) # Input throttling. + page.get_by_text("Edit this field").click() + expect(page.locator(".umap-filter span").filter(has_text="Foo Bar")).to_be_visible() + with page.expect_response(re.compile(r".*/datalayer/update/")): + page.get_by_role("button", name="Save").click() + saved = DataLayer.objects.first() + assert saved.settings["fields"] == [ + { + "key": "mytype", + "type": "String", + }, + { + "key": "name", + "type": "String", + }, + { + "key": "mynumber", + "type": "String", + }, + { + "key": "mydate", + "type": "String", + }, + { + "key": "foobar", + "type": "String", + }, + ] + + assert saved.settings["filters"] == { + "foobar": { + "label": "Foo Bar", + "widget": "checkbox", + }, + } + + +def test_can_create_new_filter_on_map_from_panel(live_server, page, openmap): + openmap.settings["properties"]["onLoadPanel"] = "datafilters" + openmap.settings["properties"]["fields"] = [ + {"key": "name", "type": "String"}, + {"key": "foobar", "type": "Number"}, + {"key": "description", "type": "Text"}, + ] + openmap.save() + DataLayerFactory(map=openmap, data=DATALAYER_DATA1) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_role("button", name="Add filter").click() + page.get_by_label("Filter on").select_option("foobar") + page.get_by_role("textbox", name="Human readable name of the").fill("Foo Bar") + page.wait_for_timeout(300) + page.get_by_text("radio").click() + page.get_by_role("button", name="OK").click() + expect( + page.locator(".panel.left").get_by_role("group", name="Foo Bar") + ).to_be_visible() + with page.expect_response(re.compile("./update/settings/.*")): + page.get_by_role("button", name="Save", exact=True).click() + saved = Map.objects.first() + assert saved.settings["properties"]["filters"] == { + "foobar": {"label": "Foo Bar", "widget": "radio"} + } + + +def test_can_create_new_filter_on_datalayer_from_panel(live_server, page, openmap): + openmap.settings["properties"]["fields"] = [ + {"key": "name", "type": "String"}, + {"key": "foobar", "type": "Number"}, + {"key": "description", "type": "Text"}, + ] + openmap.save() + datalayer = DataLayerFactory(map=openmap, data=DATALAYER_DATA1) + page.goto( + f"{live_server.url}{openmap.get_absolute_url()}?edit&onLoadPanel=datafilters" + ) + page.get_by_role("button", name="Add filter").click() + expect(page.get_by_label("Apply filter to")).to_have_value(f"map:{openmap.pk}") + page.get_by_label("Apply filter to").select_option(f"layer:{datalayer.pk}") + page.get_by_label("Filter on").select_option("mynumber") + page.get_by_role("textbox", name="Human readable name of the").fill("Foo Bar") + page.wait_for_timeout(300) + page.get_by_text("radio").click() + page.get_by_role("button", name="OK").click() + expect( + page.locator(".panel.left").get_by_role("group", name="Foo Bar") + ).to_be_visible() + with page.expect_response(re.compile("./datalayer/update/.*")): + page.get_by_role("button", name="Save", exact=True).click() + saved = DataLayer.objects.first() + assert saved.settings["filters"] == { + "mynumber": {"label": "Foo Bar", "widget": "radio"} + } + + +def test_can_edit_filter_from_panel(live_server, page, openmap): + openmap.settings["properties"]["fields"] = [ + {"key": "name", "type": "String"}, + {"key": "foobar", "type": "Number"}, + {"key": "description", "type": "Text"}, + ] + openmap.settings["properties"]["filters"] = { + "foobar": {"widget": "minmax", "label": "Foo Bar"}, + "name": {"widget": "checkbox", "label": "Bar Foo"}, + } + openmap.save() + page.goto( + f"{live_server.url}{openmap.get_absolute_url()}?edit&onLoadPanel=datafilters" + ) + page.get_by_role("group", name="Foo Bar").get_by_role("button").click() + expect(page.get_by_label("Apply filter to")).to_have_value(f"map:{openmap.pk}") + expect(page.get_by_label("Apply filter to")).to_be_disabled() + page.get_by_role("textbox", name="Human readable name of the").fill("Foo Bar Baz") + page.wait_for_timeout(300) # Input throttling. + page.get_by_role("button", name="OK").click() + expect( + page.get_by_role("group", name="Foo Bar Baz").locator("legend") + ).to_be_visible() diff --git a/umap/tests/integration/test_share.py b/umap/tests/integration/test_share.py index 2a0ccb3ed..9d960bae1 100644 --- a/umap/tests/integration/test_share.py +++ b/umap/tests/integration/test_share.py @@ -18,10 +18,10 @@ def test_iframe_code_can_contain_datalayers(map, live_server, datalayer, page): expect(textarea).not_to_have_text(re.compile(f"datalayers={datalayer.pk}")) # Open options page.get_by_text("Embed and link options").click() - page.get_by_title("Keep current visible layers").click() + page.get_by_text("Keep current visible layers").click() expect(textarea).to_have_text(re.compile(f"datalayers={datalayer.pk}")) # Now click again - page.get_by_title("Keep current visible layers").click() + page.get_by_text("Keep current visible layers").click() expect(textarea).not_to_have_text(re.compile(f"datalayers={datalayer.pk}")) @@ -33,8 +33,8 @@ def test_iframe_code_can_contain_feature(map, live_server, datalayer, page): expect(textarea).not_to_have_text(re.compile("feature=Here")) # Open options page.get_by_text("Embed and link options").click() - page.get_by_title("Open current feature on load").click() + page.get_by_text("Open current feature on load").click() expect(textarea).to_have_text(re.compile("feature=Here")) # Click again to deactivate it - page.get_by_title("Open current feature on load").click() + page.get_by_text("Open current feature on load").click() expect(textarea).not_to_have_text(re.compile("feature=Here")) diff --git a/umap/tests/integration/test_tableeditor.py b/umap/tests/integration/test_tableeditor.py index 6e21131de..c914ad4d4 100644 --- a/umap/tests/integration/test_tableeditor.py +++ b/umap/tests/integration/test_tableeditor.py @@ -4,7 +4,7 @@ from playwright.sync_api import expect -from umap.models import DataLayer +from umap.models import DataLayer, Map from ..base import DataLayerFactory @@ -79,7 +79,7 @@ def test_table_editor(live_server, openmap, datalayer, page): page.wait_for_timeout(300) # Time for the input debounce. page.keyboard.press("Enter") page.locator("thead button[data-property=name]").click() - page.get_by_role("button", name="Delete this column").click() + page.get_by_role("button", name="Delete this field").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() @@ -116,14 +116,14 @@ def test_cannot_add_property_with_a_dot(live_server, openmap, datalayer, page): expect(page.locator("table th button[data-property=name]")).to_have_count(1) -def test_rename_property(live_server, openmap, page): +def test_rename_field(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("button", name="Manage layers").click() page.locator(".panel").get_by_title("Edit properties in a table").click() expect(page.locator("table th button[data-property=mytype]")).to_have_count(1) page.locator("thead button[data-property=mytype]").click() - page.get_by_text("Rename this column").click() + page.get_by_text("Edit this field").click() page.locator("dialog").locator("input").fill("mynewtype") page.get_by_role("button", name="OK").click() expect(page.locator("table th button[data-property=mynewtype]")).to_have_count(1) @@ -142,14 +142,14 @@ def test_rename_property(live_server, openmap, page): expect(page.locator(".panel.right .umap-field-mytype")).to_be_visible() -def test_delete_property(live_server, openmap, page): +def test_delete_field(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("button", name="Manage layers").click() page.locator(".panel").get_by_title("Edit properties in a table").click() expect(page.locator("table th button[data-property=mytype]")).to_have_count(1) page.locator("thead button[data-property=mytype]").click() - page.get_by_text("Delete this column").click() + page.get_by_text("Delete this field").click() page.get_by_role("button", name="OK").click() expect(page.locator("table th button[data-property=mytype]")).to_have_count(0) @@ -202,7 +202,8 @@ def test_filter_and_delete_rows(live_server, openmap, page): 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() + page.get_by_role("button", name="Add filter for this field").click() + page.get_by_role("button", name="OK").click() expect(panel).to_be_visible() panel.get_by_label("even").check() table.locator("thead").get_by_role("checkbox").check() @@ -214,3 +215,26 @@ def test_filter_and_delete_rows(live_server, openmap, page): 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() + + +def test_add_filter_on_map_field(live_server, openmap, page): + openmap.settings["properties"]["fields"] = [{"key": "mynumber", "type": "Number"}] + openmap.save() + table = page.locator(".panel.full table") + 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("button", name="Manage layers").click() + page.locator(".panel").get_by_title("Edit properties in a table").click() + table.locator("thead button[data-property=mynumber]").click() + page.get_by_role("button", name="Add filter for this field").click() + expect(page.locator("dialog").get_by_label("minmax", exact=True)).to_be_checked() + page.locator("dialog").get_by_label("human readable name").fill("My Fun Filter") + page.wait_for_timeout(300) # Throttling… + page.get_by_role("button", name="OK").click() + expect(page.locator(".panel.left.on").get_by_text("My Fun Filter")).to_be_visible() + with page.expect_response(re.compile("./update/settings/.*")): + page.get_by_role("button", name="Save").click() + saved = Map.objects.first() + assert saved.settings["properties"]["filters"] == { + "mynumber": {"widget": "minmax", "label": "My Fun Filter"} + }