From b88a0cc49f3c39b71a89c4e4cbe4ef78da51ce7b Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 6 Jan 2025 12:29:29 +0100 Subject: [PATCH 1/8] wip: internalize FormBuilder and move to module --- package.json | 1 - scripts/vendorsjs.sh | 1 - umap/static/umap/js/modules/browser.js | 5 +- umap/static/umap/js/modules/form/builder.js | 209 +++ umap/static/umap/js/modules/form/fields.js | 1361 +++++++++++++++++ umap/static/umap/js/modules/umap.js | 27 +- umap/static/umap/js/modules/utils.js | 150 ++ umap/static/umap/js/umap.forms.js | 1242 --------------- .../formbuilder/Leaflet.FormBuilder.js | 468 ------ umap/templates/umap/js.html | 3 - 10 files changed, 1738 insertions(+), 1729 deletions(-) create mode 100644 umap/static/umap/js/modules/form/builder.js create mode 100644 umap/static/umap/js/modules/form/fields.js delete mode 100644 umap/static/umap/js/umap.forms.js delete mode 100644 umap/static/umap/vendors/formbuilder/Leaflet.FormBuilder.js diff --git a/package.json b/package.json index b20caee12..adf552b6e 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "leaflet": "1.9.4", "leaflet-editable": "^1.3.0", "leaflet-editinosm": "0.2.3", - "leaflet-formbuilder": "0.2.10", "leaflet-fullscreen": "1.0.2", "leaflet-hash": "0.2.1", "leaflet-i18n": "0.3.5", diff --git a/scripts/vendorsjs.sh b/scripts/vendorsjs.sh index ca722a2f6..7508460c5 100755 --- a/scripts/vendorsjs.sh +++ b/scripts/vendorsjs.sh @@ -17,7 +17,6 @@ mkdir -p umap/static/umap/vendors/markercluster/ && cp -r node_modules/leaflet.m mkdir -p umap/static/umap/vendors/heat/ && cp -r node_modules/leaflet.heat/dist/leaflet-heat.js umap/static/umap/vendors/heat/ mkdir -p umap/static/umap/vendors/fullscreen/ && cp -r node_modules/leaflet-fullscreen/dist/** umap/static/umap/vendors/fullscreen/ mkdir -p umap/static/umap/vendors/toolbar/ && cp -r node_modules/leaflet-toolbar/dist/leaflet.toolbar.* umap/static/umap/vendors/toolbar/ -mkdir -p umap/static/umap/vendors/formbuilder/ && cp -r node_modules/leaflet-formbuilder/Leaflet.FormBuilder.js umap/static/umap/vendors/formbuilder/ mkdir -p umap/static/umap/vendors/measurable/ && cp -r node_modules/leaflet-measurable/Leaflet.Measurable.* umap/static/umap/vendors/measurable/ mkdir -p umap/static/umap/vendors/photon/ && cp -r node_modules/leaflet.photon/leaflet.photon.js umap/static/umap/vendors/photon/ mkdir -p umap/static/umap/vendors/csv2geojson/ && cp -r node_modules/csv2geojson/csv2geojson.js umap/static/umap/vendors/csv2geojson/ diff --git a/umap/static/umap/js/modules/browser.js b/umap/static/umap/js/modules/browser.js index 3faef4cc8..01b20ebb8 100644 --- a/umap/static/umap/js/modules/browser.js +++ b/umap/static/umap/js/modules/browser.js @@ -4,6 +4,7 @@ import * as Icon from './rendering/icon.js' import * as Utils from './utils.js' import { EXPORT_FORMATS } from './formatter.js' import ContextMenu from './ui/contextmenu.js' +import { Form } from './form/builder.js' export default class Browser { constructor(umap, leafletMap) { @@ -179,7 +180,7 @@ export default class Browser { ], ['options.inBbox', { handler: 'Switch', label: translate('Current map view') }], ] - const builder = new L.FormBuilder(this, fields, { + const builder = new Form(this, fields, { callback: () => this.onFormChange(), }) let filtersBuilder @@ -189,7 +190,7 @@ export default class Browser { }) if (this._umap.properties.facetKey) { fields = this._umap.facets.build() - filtersBuilder = new L.FormBuilder(this._umap.facets, fields, { + filtersBuilder = new Form(this._umap.facets, fields, { callback: () => this.onFormChange(), }) DomEvent.on(filtersBuilder.form, 'reset', () => { diff --git a/umap/static/umap/js/modules/form/builder.js b/umap/static/umap/js/modules/form/builder.js new file mode 100644 index 000000000..86a7ac843 --- /dev/null +++ b/umap/static/umap/js/modules/form/builder.js @@ -0,0 +1,209 @@ +import getClass from './fields.js' +import * as Utils from '../utils.js' +import { SCHEMA } from '../schema.js' + +export class Form { + constructor(obj, fields, properties) { + this.setProperties(properties) + this.defaultProperties = {} + this.obj = obj + this.form = Utils.loadTemplate('
') + this.setFields(fields) + if (this.properties.id) { + this.form.id = this.properties.id + } + if (this.properties.className) { + this.form.classList.add(this.properties.className) + } + } + + setProperties(properties) { + this.properties = Object.assign({}, this.properties, properties) + } + + setFields(fields) { + this.fields = fields || [] + this.helpers = {} + } + + build() { + this.form.innerHTML = '' + for (const definition of this.fields) { + this.buildField(this.makeField(definition)) + } + return this.form + } + + buildField(field) { + field.buildLabel() + field.build() + field.buildHelpText() + } + + makeField(field) { + // field can be either a string like "option.name" or a full definition array, + // like ['properties.tilelayer.tms', {handler: 'CheckBox', helpText: 'TMS format'}] + let properties + if (Array.isArray(field)) { + properties = field[1] || {} + field = field[0] + } else { + properties = this.defaultProperties[this.getName(field)] || {} + } + const class_ = getClass(properties.handler || 'Input') + this.helpers[field] = new class_(this, field, properties) + return this.helpers[field] + } + + getter(field) { + const path = field.split('.') + let value = this.obj + for (const sub of path) { + try { + value = value[sub] + } catch { + console.log(field) + } + } + return value + } + + setter(field, value) { + const path = field.split('.') + let obj = this.obj + let what + for (let i = 0, l = path.length; i < l; i++) { + what = path[i] + if (what === path[l - 1]) { + if (typeof value === 'undefined') { + delete obj[what] + } else { + obj[what] = value + } + } else { + obj = obj[what] + } + } + } + + restoreField(field) { + const initial = this.helpers[field].initial + this.setter(field, initial) + } + + getName(field) { + const fieldEls = field.split('.') + return fieldEls[fieldEls.length - 1] + } + + fetchAll() { + for (const helper of Object.values(this.helpers)) { + helper.fetch() + } + } + + syncAll() { + for (const helper of Object.values(this.helpers)) { + helper.sync() + } + } + + onPostSync() { + if (this.properties.callback) { + this.properties.callback(this.obj) + } + } +} + +export class DataForm extends Form { + constructor(obj, fields, properties) { + super(obj, fields, properties) + this._umap = obj._umap || properties.umap + this.computeDefaultProperties() + // this.on('finish', this.finish) + } + + computeDefaultProperties() { + const customHandlers = { + sortKey: 'PropertyInput', + easing: 'Switch', + facetKey: 'PropertyInput', + slugKey: 'PropertyInput', + labelKey: 'PropertyInput', + } + for (const [key, schema] of Object.entries(SCHEMA)) { + if (schema.type === Boolean) { + if (schema.nullable) schema.handler = 'NullableChoices' + else schema.handler = 'Switch' + } else if (schema.type === 'Text') { + schema.handler = 'Textarea' + } else if (schema.type === Number) { + if (schema.step) schema.handler = 'Range' + else schema.handler = 'IntInput' + } else if (schema.choices) { + const text_length = schema.choices.reduce( + (acc, [_, label]) => acc + label.length, + 0 + ) + // Try to be smart and use MultiChoice only + // for choices where labels are shorts… + if (text_length < 40) { + schema.handler = 'MultiChoice' + } else { + schema.handler = 'Select' + schema.selectOptions = schema.choices + } + } else { + switch (key) { + case 'color': + case 'fillColor': + schema.handler = 'ColorPicker' + break + case 'iconUrl': + schema.handler = 'IconUrl' + break + case 'licence': + schema.handler = 'LicenceChooser' + break + } + } + + if (customHandlers[key]) { + schema.handler = customHandlers[key] + } + // Input uses this key for its type attribute + delete schema.type + this.defaultProperties[key] = schema + } + } + + setter(field, value) { + super.setter(field, value) + this.obj.isDirty = true + if ('render' in this.obj) { + this.obj.render([field], this) + } + if ('sync' in this.obj) { + this.obj.sync.update(field, value) + } + } + + getter(field) { + const path = field.split('.') + let value = this.obj + let sub + for (sub of path) { + try { + value = value[sub] + } catch { + console.log(field) + } + } + if (value === undefined) value = SCHEMA[sub]?.default + return value + } + + finish(event) { + event.helper?.input?.blur() + } +} diff --git a/umap/static/umap/js/modules/form/fields.js b/umap/static/umap/js/modules/form/fields.js new file mode 100644 index 000000000..9a4fe8676 --- /dev/null +++ b/umap/static/umap/js/modules/form/fields.js @@ -0,0 +1,1361 @@ +import * as Utils from '../utils.js' +import { translate } from '../i18n.js' +import { + AjaxAutocomplete, + AjaxAutocompleteMultiple, + AutocompleteDatalist, +} from '../autocomplete.js' + +const Fields = {} + +export default function getClass(name) { + if (typeof name === 'function') return name + if (!Fields[name]) throw Error(`Unknown class ${name}`) + return Fields[name] +} + +class BaseElement { + constructor(builder, field, properties) { + this.builder = builder + this.obj = this.builder.obj + this.form = this.builder.form + this.field = field + this.setProperties(properties) + this.fieldEls = this.field.split('.') + this.name = this.builder.getName(field) + this.parentNode = this.getParentNode() + } + + setProperties(properties) { + this.properties = Object.assign({}, this.properties, properties) + } + + onDefine() {} + + getParentNode() { + const classNames = ['formbox'] + if (this.properties.inheritable) { + classNames.push(inheritable) + if (this.get(true)) classNames.push('undefined') + } + classNames.push(`umap-field-${this.name}`) + const [wrapper, { header, define, undefine, quickContainer, container }] = + Utils.loadTemplateWithRefs(` +
+ +
+
`) + this.wrapper = wrapper + this.wrapper.classList.add(...classNames) + this.header = header + this.form.appendChild(this.wrapper) + if (this.properties.inheritable) { + define.addEventListener('click', (event) => { + e.preventDefault() + e.stopPropagation() + this.fetch() + this.onDefine() + this.wrapper.classList.remove('undefined') + }) + undefine.addEventListener('click', () => this.undefine()) + } else { + define.hidden = true + undefine.hidden = true + } + + this.quickContainer = quickContainer + this.extendedContainer = container + return this.extendedContainer + } + + clear() { + this.input.value = '' + } + + get(own) { + if (!this.properties.inheritable || own) return this.builder.getter(this.field) + const path = this.field.split('.') + const key = path[path.length - 1] + return this.obj.getOption(key) + } + + toHTML() { + return this.get() + } + + toJS() { + return this.value() + } + + sync() { + this.set() + this.onPostSync() + } + + set() { + this.builder.setter(this.field, this.toJS()) + } + + getLabelParent() { + return this.header + } + + getHelpTextParent() { + return this.parentNode + } + + buildLabel() { + if (this.properties.label) { + this.label = L.DomUtil.create('label', '', this.getLabelParent()) + this.label.textContent = this.label.title = this.properties.label + if (this.properties.helpEntries) { + this.builder._umap.help.button(this.label, this.properties.helpEntries) + } else if (this.properties.helpTooltip) { + const info = L.DomUtil.create('i', 'info', this.label) + L.DomEvent.on(info, 'mouseover', () => { + this.builder._umap.tooltip.open({ + anchor: info, + content: this.properties.helpTooltip, + position: 'top', + }) + }) + } + } + } + + buildHelpText() { + if (this.properties.helpText) { + const container = L.DomUtil.create('small', 'help-text', this.getHelpTextParent()) + container.innerHTML = this.properties.helpText + } + } + + fetch() {} + + finish() { + this.fireAndForward('finish') + } + + onPostSync() { + if (this.properties.callback) { + this.properties.callback(this.obj) + } + this.builder.onPostSync() + } + + undefine() { + this.wrapper.classList.add('undefined') + this.clear() + this.sync() + } +} + +Fields.Textarea = class extends BaseElement { + build() { + this.input = L.DomUtil.create( + 'textarea', + this.properties.className || '', + this.parentNode + ) + if (this.properties.placeholder) + this.input.placeholder = this.properties.placeholder + this.fetch() + L.DomEvent.on(this.input, 'input', this.sync, this) + L.DomEvent.on(this.input, 'keypress', this.onKeyPress, this) + } + + fetch() { + const value = this.toHTML() + this.initial = value + if (value) { + this.input.value = value + } + } + + value() { + return this.input.value + } + + onKeyPress(e) { + if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey)) { + L.DomEvent.stop(e) + this.finish() + } + } +} + +Fields.Input = class extends BaseElement { + build() { + this.input = L.DomUtil.create( + 'input', + this.properties.className || '', + this.parentNode + ) + this.input.type = this.type() + this.input.name = this.name + this.input._helper = this + if (this.properties.placeholder) { + this.input.placeholder = this.properties.placeholder + } + if (this.properties.min !== undefined) { + this.input.min = this.properties.min + } + if (this.properties.max !== undefined) { + this.input.max = this.properties.max + } + if (this.properties.step) { + this.input.step = this.properties.step + } + this.fetch() + L.DomEvent.on(this.input, this.getSyncEvent(), this.sync, this) + L.DomEvent.on(this.input, 'keydown', this.onKeyDown, this) + } + + fetch() { + const value = this.toHTML() !== undefined ? this.toHTML() : null + this.initial = value + this.input.value = value + } + + getSyncEvent() { + return 'input' + } + + type() { + return this.properties.type || 'text' + } + + value() { + return this.input.value || undefined + } + + onKeyDown(e) { + if (e.key === 'Enter') { + L.DomEvent.stop(e) + this.finish() + } + } +} + +Fields.BlurInput = class extends Fields.Input { + getSyncEvent() { + return 'blur' + } + + build() { + this.properties.className = 'blur' + super.build() + const button = L.DomUtil.create('span', 'button blur-button') + L.DomUtil.after(this.input, button) + this.input.addEventListener('focus', () => this.fetch()) + } + + finish() { + this.sync() + super.finish() + } + + sync() { + // Do not commit any change if user only clicked + // on the field than clicked outside + if (this.initial !== this.value()) { + super.sync() + } + } +} +const IntegerMixin = (Base) => + class extends Base { + value() { + return !isNaN(this.input.value) && this.input.value !== '' + ? parseInt(this.input.value, 10) + : undefined + } + + type() { + return 'number' + } + } + +Fields.IntInput = class extends IntegerMixin(Fields.Input) {} +Fields.BlurIntInput = class extends IntegerMixin(Fields.BlurInput) {} + +const FloatMixin = (Base) => + class extends Base { + value() { + return !isNaN(this.input.value) && this.input.value !== '' + ? parseFloat(this.input.value) + : undefined + } + + type() { + return 'number' + } + } + +Fields.FloatInput = class extends FloatMixin(Fields.Input) { + // options: { + // step: 'any', + // } +} + +Fields.BlurFloatInput = class extends FloatMixin(Fields.BlurInput) { + // options: { + // step: 'any', + // }, +} + +Fields.CheckBox = class extends BaseElement { + build() { + const container = Utils.loadTemplate('
') + this.parentNode.appendChild(container) + this.input = L.DomUtil.create('input', this.properties.className || '', container) + this.input.type = 'checkbox' + this.input.name = this.name + this.input._helper = this + this.fetch() + this.input.addEventListener('change', () => this.sync()) + } + + fetch() { + this.initial = this.toHTML() + this.input.checked = this.initial === true + } + + value() { + return this.wrapper.classList.contains('undefined') ? undefined : this.input.checked + } + + toHTML() { + return [1, true].indexOf(this.get()) !== -1 + } + + clear() { + this.fetch() + } +} + +Fields.Select = class extends BaseElement { + build() { + this.select = L.DomUtil.create('select', '', this.parentNode) + this.select.name = this.name + this.validValues = [] + this.buildOptions() + L.DomEvent.on(this.select, 'change', this.sync, this) + } + + getOptions() { + return this.properties.selectOptions + } + + fetch() { + this.buildOptions() + } + + buildOptions() { + this.select.innerHTML = '' + for (const option of this.getOptions()) { + if (typeof option === 'string') this.buildOption(option, option) + else this.buildOption(option[0], option[1]) + } + } + + buildOption(value, label) { + this.validValues.push(value) + const option = L.DomUtil.create('option', '', this.select) + option.value = value + option.innerHTML = label + if (this.toHTML() === value) { + option.selected = 'selected' + } + } + + value() { + if (this.select[this.select.selectedIndex]) + return this.select[this.select.selectedIndex].value + } + + getDefault() { + if (this.properties.inheritable) return undefined + return this.getOptions()[0][0] + } + + toJS() { + const value = this.value() + if (this.validValues.indexOf(value) !== -1) { + return value + } + return this.getDefault() + } + + clear() { + this.select.value = '' + } +} + +Fields.IntSelect = class extends Fields.Select { + value() { + return parseInt(super.value(), 10) + } +} + +Fields.NullableBoolean = class extends Fields.Select { + getOptions() { + return [ + [undefined, 'inherit'], + [true, 'yes'], + [false, 'no'], + ] + } + + toJS() { + let value = this.value() + switch (value) { + case 'true': + case true: + value = true + break + case 'false': + case false: + value = false + break + default: + value = undefined + } + return value + } +} + +Fields.EditableText = class extends BaseElement { + build() { + this.input = L.DomUtil.create( + 'span', + this.properties.className || '', + this.parentNode + ) + this.input.contentEditable = true + this.fetch() + L.DomEvent.on(this.input, 'input', this.sync, this) + L.DomEvent.on(this.input, 'keypress', this.onKeyPress, this) + } + + getParentNode() { + return this.form + } + + value() { + return this.input.textContent + } + + fetch() { + this.input.textContent = this.toHTML() + } + + onKeyPress(event) { + if (event.keyCode === 13) { + event.preventDefault() + this.input.blur() + } + } +} + +Fields.ColorPicker = class extends Fields.Input { + getColors() { + return Utils.COLORS + } + + getParentNode() { + super.getParentNode() + return this.quickContainer + } + + build() { + super.build() + this.input.placeholder = this.properties.placeholder || translate('Inherit') + this.container = L.DomUtil.create( + 'div', + 'umap-color-picker', + this.extendedContainer + ) + this.container.style.display = 'none' + for (const idx in this.colors) { + this.addColor(this.colors[idx]) + } + this.spreadColor() + this.input.autocomplete = 'off' + L.DomEvent.on(this.input, 'focus', this.onFocus, this) + L.DomEvent.on(this.input, 'blur', this.onBlur, this) + L.DomEvent.on(this.input, 'change', this.sync, this) + this.on('define', this.onFocus) + } + + onFocus() { + this.container.style.display = 'block' + this.spreadColor() + } + + onBlur() { + const closePicker = () => { + this.container.style.display = 'none' + } + // We must leave time for the click to be listened. + window.setTimeout(closePicker, 100) + } + + sync() { + this.spreadColor() + super.sync() + } + + spreadColor() { + if (this.input.value) this.input.style.backgroundColor = this.input.value + else this.input.style.backgroundColor = 'inherit' + } + + addColor(colorName) { + const span = L.DomUtil.create('span', '', this.container) + span.style.backgroundColor = span.title = colorName + const updateColorInput = function () { + this.input.value = colorName + this.sync() + this.container.style.display = 'none' + } + L.DomEvent.on(span, 'mousedown', updateColorInput, this) + } +} + +Fields.TextColorPicker = class extends Fields.ColorPicker { + getColors() { + return [ + 'Black', + 'DarkSlateGrey', + 'DimGrey', + 'SlateGrey', + 'LightSlateGrey', + 'Grey', + 'DarkGrey', + 'LightGrey', + 'White', + ] + } +} + +Fields.LayerTypeChooser = class extends Fields.Select { + getOptions() { + return U.LAYER_TYPES.map((class_) => [class_.TYPE, class_.NAME]) + } +} + +Fields.SlideshowDelay = class extends Fields.IntSelect { + getOptions() { + const options = [] + for (let i = 1; i < 30; i++) { + options.push([i * 1000, translate('{delay} seconds', { delay: i })]) + } + return options + } +} + +Fields.DataLayerSwitcher = class extends Fields.Select { + getOptions() { + const options = [] + this.builder._umap.eachDataLayerReverse((datalayer) => { + if ( + datalayer.isLoaded() && + !datalayer.isDataReadOnly() && + datalayer.isBrowsable() + ) { + options.push([L.stamp(datalayer), datalayer.getName()]) + } + }) + return options + } + + toHTML() { + return L.stamp(this.obj.datalayer) + } + + toJS() { + return this.builder._umap.datalayers[this.value()] + } + + set() { + this.builder._umap.lastUsedDataLayer = this.toJS() + this.obj.changeDataLayer(this.toJS()) + } +} + +Fields.DataFormat = class extends Fields.Select { + getOptions() { + return [ + [undefined, translate('Choose the data format')], + ['geojson', 'geojson'], + ['osm', 'osm'], + ['csv', 'csv'], + ['gpx', 'gpx'], + ['kml', 'kml'], + ['georss', 'georss'], + ] + } +} + +Fields.LicenceChooser = class extends Fields.Select { + getOptions() { + const licences = [] + const licencesList = this.builder.obj.properties.licences + let licence + for (const i in licencesList) { + licence = licencesList[i] + licences.push([i, licence.name]) + } + return licences + } + + toHTML() { + return this.get()?.name + } + + toJS() { + return this.builder.obj.properties.licences[this.value()] + } +} + +Fields.NullableBoolean = class extends Fields.Select { + getOptions() { + return [ + [undefined, translate('inherit')], + [true, translate('yes')], + [false, translate('no')], + ] + } + + toJS() { + let value = this.value() + switch (value) { + case 'true': + case true: + value = true + break + case 'false': + case false: + value = false + break + default: + value = undefined + } + return value + } +} + +// Adds an autocomplete using all available user defined properties +Fields.PropertyInput = class extends Fields.BlurInput { + build() { + super.build() + const autocomplete = new AutocompleteDatalist(this.input) + // Will be used on Umap and DataLayer + const properties = this.builder.obj.allProperties() + autocomplete.suggestions = properties + } +} + +Fields.IconUrl = class extends Fields.BlurInput { + type() { + return 'hidden' + } + + build() { + super.build() + this.buttons = L.DomUtil.create('div', '', this.parentNode) + this.tabs = L.DomUtil.create('div', 'flat-tabs', this.parentNode) + this.body = L.DomUtil.create('div', 'umap-pictogram-body', this.parentNode) + this.footer = L.DomUtil.create('div', '', this.parentNode) + this.updatePreview() + this.on('define', this.onDefine) + } + + async onDefine() { + this.buttons.innerHTML = '' + this.footer.innerHTML = '' + const [{ pictogram_list }, response, error] = await this.builder._umap.server.get( + this.builder._umap.properties.urls.pictogram_list_json + ) + if (!error) this.pictogram_list = pictogram_list + this.buildTabs() + const value = this.value() + if (U.Icon.RECENT.length) this.showRecentTab() + else if (!value || Utils.isPath(value)) this.showSymbolsTab() + else if (Utils.isRemoteUrl(value) || Utils.isDataImage(value)) this.showURLTab() + else this.showCharsTab() + const closeButton = L.DomUtil.createButton( + 'button action-button', + this.footer, + translate('Close'), + function (e) { + this.body.innerHTML = '' + this.tabs.innerHTML = '' + this.footer.innerHTML = '' + if (this.isDefault()) this.undefine(e) + else this.updatePreview() + }, + this + ) + } + + buildTabs() { + this.tabs.innerHTML = '' + if (U.Icon.RECENT.length) { + const recent = L.DomUtil.add( + 'button', + 'flat tab-recent', + this.tabs, + translate('Recent') + ) + L.DomEvent.on(recent, 'click', L.DomEvent.stop).on( + recent, + 'click', + this.showRecentTab, + this + ) + } + const symbol = L.DomUtil.add( + 'button', + 'flat tab-symbols', + this.tabs, + translate('Symbol') + ) + const char = L.DomUtil.add( + 'button', + 'flat tab-chars', + this.tabs, + translate('Emoji & Character') + ) + url = L.DomUtil.add('button', 'flat tab-url', this.tabs, translate('URL')) + L.DomEvent.on(symbol, 'click', L.DomEvent.stop).on( + symbol, + 'click', + this.showSymbolsTab, + this + ) + L.DomEvent.on(char, 'click', L.DomEvent.stop).on( + char, + 'click', + this.showCharsTab, + this + ) + L.DomEvent.on(url, 'click', L.DomEvent.stop).on(url, 'click', this.showURLTab, this) + } + + openTab(name) { + const els = this.tabs.querySelectorAll('button') + for (const el of els) { + L.DomUtil.removeClass(el, 'on') + } + const el = this.tabs.querySelector(`.tab-${name}`) + L.DomUtil.addClass(el, 'on') + this.body.innerHTML = '' + } + + updatePreview() { + this.buttons.innerHTML = '' + if (this.isDefault()) return + if (!Utils.hasVar(this.value())) { + // Do not try to render URL with variables + const box = L.DomUtil.create('div', 'umap-pictogram-choice', this.buttons) + L.DomEvent.on(box, 'click', this.onDefine, this) + const icon = U.Icon.makeElement(this.value(), box) + } + this.button = L.DomUtil.createButton( + 'button action-button', + this.buttons, + this.value() ? translate('Change') : translate('Add'), + this.onDefine, + this + ) + } + + addIconPreview(pictogram, parent) { + const baseClass = 'umap-pictogram-choice' + const value = pictogram.src + const search = Utils.normalize(this.searchInput.value) + const title = pictogram.attribution + ? `${pictogram.name} — © ${pictogram.attribution}` + : pictogram.name || pictogram.src + if (search && Utils.normalize(title).indexOf(search) === -1) return + const className = value === this.value() ? `${baseClass} selected` : baseClass + const container = L.DomUtil.create('div', className, parent) + U.Icon.makeElement(value, container) + container.title = title + L.DomEvent.on( + container, + 'click', + function (e) { + this.input.value = value + this.sync() + this.unselectAll(this.grid) + L.DomUtil.addClass(container, 'selected') + }, + this + ) + return true // Icon has been added (not filtered) + } + + clear() { + this.input.value = '' + this.unselectAll(this.body) + this.sync() + this.body.innerHTML = '' + this.updatePreview() + } + + addCategory(items, name) { + const parent = L.DomUtil.create('div', 'umap-pictogram-category') + if (name) L.DomUtil.add('h6', '', parent, name) + const grid = L.DomUtil.create('div', 'umap-pictogram-grid', parent) + let status = false + for (const item of items) { + status = this.addIconPreview(item, grid) || status + } + if (status) this.grid.appendChild(parent) + } + + buildSymbolsList() { + this.grid.innerHTML = '' + const categories = {} + let category + for (const props of this.pictogram_list) { + category = props.category || translate('Generic') + categories[category] = categories[category] || [] + categories[category].push(props) + } + const sorted = Object.entries(categories).toSorted(([a], [b]) => + Utils.naturalSort(a, b, U.lang) + ) + for (const [name, items] of sorted) { + this.addCategory(items, name) + } + } + + buildRecentList() { + this.grid.innerHTML = '' + const items = U.Icon.RECENT.map((src) => ({ + src, + })) + this.addCategory(items) + } + + isDefault() { + return !this.value() || this.value() === U.SCHEMA.iconUrl.default + } + + addGrid(onSearch) { + this.searchInput = L.DomUtil.create('input', '', this.body) + this.searchInput.type = 'search' + this.searchInput.placeholder = translate('Search') + this.grid = L.DomUtil.create('div', '', this.body) + L.DomEvent.on(this.searchInput, 'input', onSearch, this) + } + + showRecentTab() { + if (!U.Icon.RECENT.length) return + this.openTab('recent') + this.addGrid(this.buildRecentList) + this.buildRecentList() + } + + showSymbolsTab() { + this.openTab('symbols') + this.addGrid(this.buildSymbolsList) + this.buildSymbolsList() + } + + showCharsTab() { + this.openTab('chars') + const value = !U.Icon.isImg(this.value()) ? this.value() : null + const input = this.buildInput(this.body, value) + input.placeholder = translate('Type char or paste emoji') + input.type = 'text' + } + + showURLTab() { + this.openTab('url') + const value = + Utils.isRemoteUrl(this.value()) || Utils.isDataImage(this.value()) + ? this.value() + : null + const input = this.buildInput(this.body, value) + input.placeholder = translate('Add image URL') + input.type = 'url' + } + + buildInput(parent, value) { + const input = L.DomUtil.create('input', 'blur', parent) + const button = L.DomUtil.create('span', 'button blur-button', parent) + if (value) input.value = value + L.DomEvent.on(input, 'blur', () => { + // Do not clear this.input when focus-blur + // empty input + if (input.value === value) return + this.input.value = input.value + this.sync() + }) + return input + } + + unselectAll(container) { + for (const el of container.querySelectorAll('div.selected')) { + el.classList.remove('selected') + } + } +} + +Fields.Url = class extends Fields.Input { + type() { + return 'url' + } +} + +Fields.Switch = class extends Fields.CheckBox { + getParentNode() { + super.getParentNode() + if (this.properties.inheritable) return this.quickContainer + return this.extendedContainer + } + + build() { + super.build() + console.log(this) + if (this.properties.inheritable) { + this.label = Utils.loadTemplate('') + } + this.input.parentNode.appendChild(this.label) + L.DomUtil.addClass(this.input.parentNode, 'with-switch') + const id = `${this.builder.properties.id || Date.now()}.${this.name}` + this.label.setAttribute('for', id) + L.DomUtil.addClass(this.input, 'switch') + this.input.id = id + } +} + +Fields.FacetSearchBase = class extends BaseElement { + buildLabel() { + this.label = L.DomUtil.element({ + tagName: 'legend', + textContent: this.properties.label, + }) + } +} + +Fields.FacetSearchChoices = class extends Fields.FacetSearchBase { + build() { + this.container = L.DomUtil.create('fieldset', 'umap-facet', this.parentNode) + this.container.appendChild(this.label) + this.ul = L.DomUtil.create('ul', '', this.container) + this.type = this.properties.criteria.type + + const choices = this.properties.criteria.choices + choices.sort() + choices.forEach((value) => this.buildLi(value)) + } + + buildLi(value) { + const property_li = L.DomUtil.create('li', '', this.ul) + const label = L.DomUtil.create('label', '', property_li) + const input = L.DomUtil.create('input', '', label) + L.DomUtil.add('span', '', label, value) + + input.type = this.type + input.name = `${this.type}_${this.name}` + input.checked = this.get().choices.includes(value) + input.dataset.value = value + + L.DomEvent.on(input, 'change', (e) => this.sync()) + } + + toJS() { + return { + type: this.type, + choices: [...this.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() + } + + build() { + this.container = L.DomUtil.create('fieldset', 'umap-facet', this.parentNode) + this.container.appendChild(this.label) + 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 + this.type = type + this.inputType = this.getInputType(this.type) + + const [minLabel, maxLabel] = this.getLabels() + + this.minLabel = L.DomUtil.create('label', '', this.container) + this.minLabel.textContent = minLabel + + this.minInput = L.DomUtil.create('input', '', this.minLabel) + this.minInput.type = this.inputType + this.minInput.step = 'any' + this.minInput.min = this.prepareForHTML(min) + this.minInput.max = this.prepareForHTML(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) + } + + this.maxLabel = L.DomUtil.create('label', '', this.container) + this.maxLabel.textContent = maxLabel + + this.maxInput = L.DomUtil.create('input', '', this.maxLabel) + this.maxInput.type = this.inputType + this.maxInput.step = 'any' + this.maxInput.min = this.prepareForHTML(min) + this.maxInput.max = this.prepareForHTML(max) + 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() + + L.DomEvent.on(this.minInput, 'change', () => this.sync()) + L.DomEvent.on(this.maxInput, 'change', () => this.sync()) + } + + 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 { + getDefault() { + return 'null' + } + getClassName() { + return 'umap-multiplechoice' + } + + clear() { + const checked = this.container.querySelector('input[type="radio"]:checked') + if (checked) checked.checked = false + } + + fetch() { + this.initial = this.toHTML() + let value = this.initial + if (!this.container.querySelector(`input[type="radio"][value="${value}"]`)) { + value = + this.properties.default !== undefined ? this.properties.default : this.default + } + const choices = this.getChoices().map(([value, label]) => `${value}`) + if (choices.includes(`${value}`)) { + this.container.querySelector(`input[type="radio"][value="${value}"]`).checked = + true + } + } + + value() { + const checked = this.container.querySelector('input[type="radio"]:checked') + if (checked) return checked.value + } + + getChoices() { + return this.properties.choices || this.choices + } + + build() { + const choices = this.getChoices() + this.container = L.DomUtil.create( + 'div', + `${this.className} by${choices.length}`, + this.parentNode + ) + for (const [i, [value, label]] of choices.entries()) { + this.addChoice(value, label, i) + } + this.fetch() + } + + addChoice(value, label, counter) { + const input = L.DomUtil.create('input', '', this.container) + label = L.DomUtil.add('label', '', this.container, label) + input.type = 'radio' + input.name = this.name + input.value = value + const id = `${Date.now()}.${this.name}.${counter}` + label.setAttribute('for', id) + input.id = id + L.DomEvent.on(input, 'change', this.sync, this) + } +} + +Fields.TernaryChoices = class extends Fields.MultiChoice { + getDefault() { + return 'null' + } + + toJS() { + let value = this.value() + switch (value) { + case 'true': + case true: + value = true + break + case 'false': + case false: + value = false + break + case 'null': + case null: + value = null + break + default: + value = undefined + } + return value + } +} + +Fields.NullableChoices = class extends Fields.TernaryChoices { + getChoices() { + return [ + [true, translate('always')], + [false, translate('never')], + ['null', translate('hidden')], + ] + } +} + +Fields.DataLayersControl = class extends Fields.TernaryChoices { + getChoices() { + return [ + [true, translate('collapsed')], + ['expanded', translate('expanded')], + [false, translate('never')], + ['null', translate('hidden')], + ] + } + + toJS() { + let value = this.value() + if (value !== 'expanded') value = super.toJS() + return value + } +} + +Fields.Range = class extends Fields.FloatInput { + type() { + return 'range' + } + + value() { + return this.wrapper.classList.contains('undefined') ? undefined : super.value() + } + + buildHelpText() { + let options = '' + const step = this.properties.step || 1 + const digits = step < 1 ? 1 : 0 + const id = `range-${this.properties.label || this.name}` + for ( + let i = this.properties.min; + i <= this.properties.max; + i += this.properties.step + ) { + options += `` + } + const datalist = L.DomUtil.element({ + tagName: 'datalist', + parent: this.getHelpTextParent(), + className: 'umap-field-datalist', + safeHTML: options, + id: id, + }) + this.input.setAttribute('list', id) + super.buildHelpText() + } +} + +Fields.ManageOwner = class extends BaseElement { + build() { + const options = { + className: 'edit-owner', + on_select: L.bind(this.onSelect, this), + placeholder: translate("Type new owner's username"), + } + this.autocomplete = new AjaxAutocomplete(this.parentNode, options) + const owner = this.toHTML() + if (owner) + this.autocomplete.displaySelected({ + item: { value: owner.id, label: owner.name }, + }) + } + + value() { + return this._value + } + + onSelect(choice) { + this._value = { + id: choice.item.value, + name: choice.item.label, + url: choice.item.url, + } + this.set() + } +} + +Fields.ManageEditors = class extends BaseElement { + build() { + const options = { + className: 'edit-editors', + on_select: L.bind(this.onSelect, this), + on_unselect: L.bind(this.onUnselect, this), + placeholder: translate("Type editor's username"), + } + this.autocomplete = new AjaxAutocompleteMultiple(this.parentNode, options) + this._values = this.toHTML() + if (this._values) + for (let i = 0; i < this._values.length; i++) + this.autocomplete.displaySelected({ + item: { value: this._values[i].id, label: this._values[i].name }, + }) + } + + value() { + return this._values + } + + onSelect(choice) { + this._values.push({ + id: choice.item.value, + name: choice.item.label, + url: choice.item.url, + }) + this.set() + } + + onUnselect(choice) { + const index = this._values.findIndex((item) => item.id === choice.item.value) + if (index !== -1) { + this._values.splice(index, 1) + this.set() + } + } +} + +Fields.ManageTeam = class extends Fields.IntSelect { + getOptions() { + return [[null, translate('None')]].concat( + this.properties.teams.map((team) => [team.id, team.name]) + ) + } + + toHTML() { + return this.get()?.id + } + + toJS() { + const value = this.value() + for (const team of this.properties.teams) { + if (team.id === value) return team + } + } +} diff --git a/umap/static/umap/js/modules/umap.js b/umap/static/umap/js/modules/umap.js index 57ffc46b1..543a5a94c 100644 --- a/umap/static/umap/js/modules/umap.js +++ b/umap/static/umap/js/modules/umap.js @@ -34,6 +34,7 @@ import { uMapAlert as Alert, } from '../components/alerts/alert.js' import Orderable from './orderable.js' +import { DataForm } from './form/builder.js' export default class Umap extends ServerStored { constructor(element, geojson) { @@ -734,7 +735,7 @@ export default class Umap extends ServerStored { const metadataFields = ['properties.name', 'properties.description'] DomUtil.createTitle(container, translate('Edit map details'), 'icon-caption') - const builder = new U.FormBuilder(this, metadataFields, { + const builder = new DataForm(this, metadataFields, { className: 'map-metadata', umap: this, }) @@ -749,7 +750,7 @@ export default class Umap extends ServerStored { 'properties.permanentCredit', 'properties.permanentCreditBackground', ] - const creditsBuilder = new U.FormBuilder(this, creditsFields, { umap: this }) + const creditsBuilder = new DataForm(this, creditsFields, { umap: this }) credits.appendChild(creditsBuilder.build()) this.editPanel.open({ content: container }) } @@ -770,7 +771,7 @@ export default class Umap extends ServerStored { 'properties.captionBar', 'properties.captionMenus', ]) - const builder = new U.FormBuilder(this, UIFields, { umap: this }) + const builder = new DataForm(this, UIFields, { umap: this }) const controlsOptions = DomUtil.createFieldset( container, translate('User interface options') @@ -793,7 +794,7 @@ export default class Umap extends ServerStored { 'properties.dashArray', ] - const builder = new U.FormBuilder(this, shapeOptions, { umap: this }) + const builder = new DataForm(this, shapeOptions, { umap: this }) const defaultShapeProperties = DomUtil.createFieldset( container, translate('Default shape properties') @@ -812,7 +813,7 @@ export default class Umap extends ServerStored { 'properties.slugKey', ] - const builder = new U.FormBuilder(this, optionsFields, { umap: this }) + const builder = new DataForm(this, optionsFields, { umap: this }) const defaultProperties = DomUtil.createFieldset( container, translate('Default properties') @@ -830,7 +831,7 @@ export default class Umap extends ServerStored { 'properties.labelInteractive', 'properties.outlinkTarget', ] - const builder = new U.FormBuilder(this, popupFields, { umap: this }) + const builder = new DataForm(this, popupFields, { umap: this }) const popupFieldset = DomUtil.createFieldset( container, translate('Default interaction options') @@ -887,7 +888,7 @@ export default class Umap extends ServerStored { container, translate('Custom background') ) - const builder = new U.FormBuilder(this, tilelayerFields, { umap: this }) + const builder = new DataForm(this, tilelayerFields, { umap: this }) customTilelayer.appendChild(builder.build()) } @@ -935,7 +936,7 @@ export default class Umap extends ServerStored { ['properties.overlay.tms', { handler: 'Switch', label: translate('TMS format') }], ] const overlay = DomUtil.createFieldset(container, translate('Custom overlay')) - const builder = new U.FormBuilder(this, overlayFields, { umap: this }) + const builder = new DataForm(this, overlayFields, { umap: this }) overlay.appendChild(builder.build()) } @@ -962,7 +963,7 @@ export default class Umap extends ServerStored { { handler: 'BlurFloatInput', placeholder: translate('max East') }, ], ] - const boundsBuilder = new U.FormBuilder(this, boundsFields, { umap: this }) + const boundsBuilder = new DataForm(this, boundsFields, { umap: this }) limitBounds.appendChild(boundsBuilder.build()) const boundsButtons = DomUtil.create('div', 'button-bar half', limitBounds) DomUtil.createButton( @@ -1027,7 +1028,7 @@ export default class Umap extends ServerStored { { handler: 'Switch', label: translate('Autostart when map is loaded') }, ], ] - const slideshowBuilder = new U.FormBuilder(this, slideshowFields, { + const slideshowBuilder = new DataForm(this, slideshowFields, { callback: () => { this.slideshow.load() // FIXME when we refactor formbuilder: this callback is called in a 'postsync' @@ -1042,7 +1043,9 @@ export default class Umap extends ServerStored { _editSync(container) { const sync = DomUtil.createFieldset(container, translate('Real-time collaboration')) - const builder = new U.FormBuilder(this, ['properties.syncEnabled'], { umap: this }) + const builder = new DataForm(this, ['properties.syncEnabled'], { + umap: this, + }) sync.appendChild(builder.build()) } @@ -1459,7 +1462,7 @@ export default class Umap extends ServerStored { const row = DomUtil.create('li', 'orderable', ul) DomUtil.createIcon(row, 'icon-drag', translate('Drag to reorder')) datalayer.renderToolbox(row) - const builder = new U.FormBuilder( + const builder = new DataForm( datalayer, [['options.name', { handler: 'EditableText' }]], { className: 'umap-form-inline' } diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js index 2f70edf45..b5c4664f6 100644 --- a/umap/static/umap/js/modules/utils.js +++ b/umap/static/umap/js/modules/utils.js @@ -446,3 +446,153 @@ export function eachElement(selector, callback) { callback(el) } } + +export const COLORS = [ + 'Black', + 'Navy', + 'DarkBlue', + 'MediumBlue', + 'Blue', + 'DarkGreen', + 'Green', + 'Teal', + 'DarkCyan', + 'DeepSkyBlue', + 'DarkTurquoise', + 'MediumSpringGreen', + 'Lime', + 'SpringGreen', + 'Aqua', + 'Cyan', + 'MidnightBlue', + 'DodgerBlue', + 'LightSeaGreen', + 'ForestGreen', + 'SeaGreen', + 'DarkSlateGray', + 'DarkSlateGrey', + 'LimeGreen', + 'MediumSeaGreen', + 'Turquoise', + 'RoyalBlue', + 'SteelBlue', + 'DarkSlateBlue', + 'MediumTurquoise', + 'Indigo', + 'DarkOliveGreen', + 'CadetBlue', + 'CornflowerBlue', + 'MediumAquaMarine', + 'DimGray', + 'DimGrey', + 'SlateBlue', + 'OliveDrab', + 'SlateGray', + 'SlateGrey', + 'LightSlateGray', + 'LightSlateGrey', + 'MediumSlateBlue', + 'LawnGreen', + 'Chartreuse', + 'Aquamarine', + 'Maroon', + 'Purple', + 'Olive', + 'Gray', + 'Grey', + 'SkyBlue', + 'LightSkyBlue', + 'BlueViolet', + 'DarkRed', + 'DarkMagenta', + 'SaddleBrown', + 'DarkSeaGreen', + 'LightGreen', + 'MediumPurple', + 'DarkViolet', + 'PaleGreen', + 'DarkOrchid', + 'YellowGreen', + 'Sienna', + 'Brown', + 'DarkGray', + 'DarkGrey', + 'LightBlue', + 'GreenYellow', + 'PaleTurquoise', + 'LightSteelBlue', + 'PowderBlue', + 'FireBrick', + 'DarkGoldenRod', + 'MediumOrchid', + 'RosyBrown', + 'DarkKhaki', + 'Silver', + 'MediumVioletRed', + 'IndianRed', + 'Peru', + 'Chocolate', + 'Tan', + 'LightGray', + 'LightGrey', + 'Thistle', + 'Orchid', + 'GoldenRod', + 'PaleVioletRed', + 'Crimson', + 'Gainsboro', + 'Plum', + 'BurlyWood', + 'LightCyan', + 'Lavender', + 'DarkSalmon', + 'Violet', + 'PaleGoldenRod', + 'LightCoral', + 'Khaki', + 'AliceBlue', + 'HoneyDew', + 'Azure', + 'SandyBrown', + 'Wheat', + 'Beige', + 'WhiteSmoke', + 'MintCream', + 'GhostWhite', + 'Salmon', + 'AntiqueWhite', + 'Linen', + 'LightGoldenRodYellow', + 'OldLace', + 'Red', + 'Fuchsia', + 'Magenta', + 'DeepPink', + 'OrangeRed', + 'Tomato', + 'HotPink', + 'Coral', + 'DarkOrange', + 'LightSalmon', + 'Orange', + 'LightPink', + 'Pink', + 'Gold', + 'PeachPuff', + 'NavajoWhite', + 'Moccasin', + 'Bisque', + 'MistyRose', + 'BlanchedAlmond', + 'PapayaWhip', + 'LavenderBlush', + 'SeaShell', + 'Cornsilk', + 'LemonChiffon', + 'FloralWhite', + 'Snow', + 'Yellow', + 'LightYellow', + 'Ivory', + 'White', +] diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js deleted file mode 100644 index dc90d168b..000000000 --- a/umap/static/umap/js/umap.forms.js +++ /dev/null @@ -1,1242 +0,0 @@ -U.COLORS = [ - 'Black', - 'Navy', - 'DarkBlue', - 'MediumBlue', - 'Blue', - 'DarkGreen', - 'Green', - 'Teal', - 'DarkCyan', - 'DeepSkyBlue', - 'DarkTurquoise', - 'MediumSpringGreen', - 'Lime', - 'SpringGreen', - 'Aqua', - 'Cyan', - 'MidnightBlue', - 'DodgerBlue', - 'LightSeaGreen', - 'ForestGreen', - 'SeaGreen', - 'DarkSlateGray', - 'DarkSlateGrey', - 'LimeGreen', - 'MediumSeaGreen', - 'Turquoise', - 'RoyalBlue', - 'SteelBlue', - 'DarkSlateBlue', - 'MediumTurquoise', - 'Indigo', - 'DarkOliveGreen', - 'CadetBlue', - 'CornflowerBlue', - 'MediumAquaMarine', - 'DimGray', - 'DimGrey', - 'SlateBlue', - 'OliveDrab', - 'SlateGray', - 'SlateGrey', - 'LightSlateGray', - 'LightSlateGrey', - 'MediumSlateBlue', - 'LawnGreen', - 'Chartreuse', - 'Aquamarine', - 'Maroon', - 'Purple', - 'Olive', - 'Gray', - 'Grey', - 'SkyBlue', - 'LightSkyBlue', - 'BlueViolet', - 'DarkRed', - 'DarkMagenta', - 'SaddleBrown', - 'DarkSeaGreen', - 'LightGreen', - 'MediumPurple', - 'DarkViolet', - 'PaleGreen', - 'DarkOrchid', - 'YellowGreen', - 'Sienna', - 'Brown', - 'DarkGray', - 'DarkGrey', - 'LightBlue', - 'GreenYellow', - 'PaleTurquoise', - 'LightSteelBlue', - 'PowderBlue', - 'FireBrick', - 'DarkGoldenRod', - 'MediumOrchid', - 'RosyBrown', - 'DarkKhaki', - 'Silver', - 'MediumVioletRed', - 'IndianRed', - 'Peru', - 'Chocolate', - 'Tan', - 'LightGray', - 'LightGrey', - 'Thistle', - 'Orchid', - 'GoldenRod', - 'PaleVioletRed', - 'Crimson', - 'Gainsboro', - 'Plum', - 'BurlyWood', - 'LightCyan', - 'Lavender', - 'DarkSalmon', - 'Violet', - 'PaleGoldenRod', - 'LightCoral', - 'Khaki', - 'AliceBlue', - 'HoneyDew', - 'Azure', - 'SandyBrown', - 'Wheat', - 'Beige', - 'WhiteSmoke', - 'MintCream', - 'GhostWhite', - 'Salmon', - 'AntiqueWhite', - 'Linen', - 'LightGoldenRodYellow', - 'OldLace', - 'Red', - 'Fuchsia', - 'Magenta', - 'DeepPink', - 'OrangeRed', - 'Tomato', - 'HotPink', - 'Coral', - 'DarkOrange', - 'LightSalmon', - 'Orange', - 'LightPink', - 'Pink', - 'Gold', - 'PeachPuff', - 'NavajoWhite', - 'Moccasin', - 'Bisque', - 'MistyRose', - 'BlanchedAlmond', - 'PapayaWhip', - 'LavenderBlush', - 'SeaShell', - 'Cornsilk', - 'LemonChiffon', - 'FloralWhite', - 'Snow', - 'Yellow', - 'LightYellow', - 'Ivory', - 'White', -] - -L.FormBuilder.Element.include({ - undefine: function () { - L.DomUtil.addClass(this.wrapper, 'undefined') - this.clear() - this.sync() - }, - - getParentNode: function () { - if (this.options.wrapper) { - return L.DomUtil.create( - this.options.wrapper, - this.options.wrapperClass || '', - this.form - ) - } - let className = 'formbox' - if (this.options.inheritable) { - className += - this.get(true) === undefined ? ' inheritable undefined' : ' inheritable ' - } - className += ` umap-field-${this.name}` - this.wrapper = L.DomUtil.create('div', className, this.form) - this.header = L.DomUtil.create('div', 'header', this.wrapper) - if (this.options.inheritable) { - const undefine = L.DomUtil.add('a', 'button undefine', this.header, L._('clear')) - const define = L.DomUtil.add('a', 'button define', this.header, L._('define')) - L.DomEvent.on( - define, - 'click', - function (e) { - L.DomEvent.stop(e) - this.fetch() - this.fire('define') - L.DomUtil.removeClass(this.wrapper, 'undefined') - }, - this - ) - L.DomEvent.on(undefine, 'click', L.DomEvent.stop).on( - undefine, - 'click', - this.undefine, - this - ) - } - this.quickContainer = L.DomUtil.create( - 'span', - 'quick-actions show-on-defined', - this.header - ) - this.extendedContainer = L.DomUtil.create('div', 'show-on-defined', this.wrapper) - return this.extendedContainer - }, - - getLabelParent: function () { - return this.header - }, - - clear: function () { - this.input.value = '' - }, - - get: function (own) { - if (!this.options.inheritable || own) return this.builder.getter(this.field) - const path = this.field.split('.') - const key = path[path.length - 1] - return this.obj.getOption(key) - }, - - buildLabel: function () { - if (this.options.label) { - this.label = L.DomUtil.create('label', '', this.getLabelParent()) - this.label.textContent = this.label.title = this.options.label - if (this.options.helpEntries) { - this.builder._umap.help.button(this.label, this.options.helpEntries) - } else if (this.options.helpTooltip) { - const info = L.DomUtil.create('i', 'info', this.label) - L.DomEvent.on(info, 'mouseover', () => { - this.builder._umap.tooltip.open({ - anchor: info, - content: this.options.helpTooltip, - position: 'top', - }) - }) - } - } - }, -}) - -L.FormBuilder.Select.include({ - clear: function () { - this.select.value = '' - }, - - getDefault: function () { - if (this.options.inheritable) return undefined - return this.getOptions()[0][0] - }, -}) - -L.FormBuilder.CheckBox.include({ - value: function () { - return L.DomUtil.hasClass(this.wrapper, 'undefined') - ? undefined - : this.input.checked - }, - - clear: function () { - this.fetch() - }, -}) - -L.FormBuilder.EditableText = L.FormBuilder.Element.extend({ - build: function () { - this.input = L.DomUtil.create('span', this.options.className || '', this.parentNode) - this.input.contentEditable = true - this.fetch() - L.DomEvent.on(this.input, 'input', this.sync, this) - L.DomEvent.on(this.input, 'keypress', this.onKeyPress, this) - }, - - getParentNode: function () { - return this.form - }, - - value: function () { - return this.input.textContent - }, - - fetch: function () { - this.input.textContent = this.toHTML() - }, - - onKeyPress: function (event) { - if (event.keyCode === 13) { - event.preventDefault() - this.input.blur() - } - }, -}) - -L.FormBuilder.ColorPicker = L.FormBuilder.Input.extend({ - colors: U.COLORS, - getParentNode: function () { - L.FormBuilder.CheckBox.prototype.getParentNode.call(this) - return this.quickContainer - }, - - build: function () { - L.FormBuilder.Input.prototype.build.call(this) - this.input.placeholder = this.options.placeholder || L._('Inherit') - this.container = L.DomUtil.create( - 'div', - 'umap-color-picker', - this.extendedContainer - ) - this.container.style.display = 'none' - for (const idx in this.colors) { - this.addColor(this.colors[idx]) - } - this.spreadColor() - this.input.autocomplete = 'off' - L.DomEvent.on(this.input, 'focus', this.onFocus, this) - L.DomEvent.on(this.input, 'blur', this.onBlur, this) - L.DomEvent.on(this.input, 'change', this.sync, this) - this.on('define', this.onFocus) - }, - - onFocus: function () { - this.container.style.display = 'block' - this.spreadColor() - }, - - onBlur: function () { - const closePicker = () => { - this.container.style.display = 'none' - } - // We must leave time for the click to be listened. - window.setTimeout(closePicker, 100) - }, - - sync: function () { - this.spreadColor() - L.FormBuilder.Input.prototype.sync.call(this) - }, - - spreadColor: function () { - if (this.input.value) this.input.style.backgroundColor = this.input.value - else this.input.style.backgroundColor = 'inherit' - }, - - addColor: function (colorName) { - const span = L.DomUtil.create('span', '', this.container) - span.style.backgroundColor = span.title = colorName - const updateColorInput = function () { - this.input.value = colorName - this.sync() - this.container.style.display = 'none' - } - L.DomEvent.on(span, 'mousedown', updateColorInput, this) - }, -}) - -L.FormBuilder.TextColorPicker = L.FormBuilder.ColorPicker.extend({ - colors: [ - 'Black', - 'DarkSlateGrey', - 'DimGrey', - 'SlateGrey', - 'LightSlateGrey', - 'Grey', - 'DarkGrey', - 'LightGrey', - 'White', - ], -}) - -L.FormBuilder.LayerTypeChooser = L.FormBuilder.Select.extend({ - getOptions: () => { - return U.LAYER_TYPES.map((class_) => [class_.TYPE, class_.NAME]) - }, -}) - -L.FormBuilder.SlideshowDelay = L.FormBuilder.IntSelect.extend({ - getOptions: () => { - const options = [] - for (let i = 1; i < 30; i++) { - options.push([i * 1000, L._('{delay} seconds', { delay: i })]) - } - return options - }, -}) - -L.FormBuilder.DataLayerSwitcher = L.FormBuilder.Select.extend({ - getOptions: function () { - const options = [] - this.builder._umap.eachDataLayerReverse((datalayer) => { - if ( - datalayer.isLoaded() && - !datalayer.isDataReadOnly() && - datalayer.isBrowsable() - ) { - options.push([L.stamp(datalayer), datalayer.getName()]) - } - }) - return options - }, - - toHTML: function () { - return L.stamp(this.obj.datalayer) - }, - - toJS: function () { - return this.builder._umap.datalayers[this.value()] - }, - - set: function () { - this.builder._umap.lastUsedDataLayer = this.toJS() - this.obj.changeDataLayer(this.toJS()) - }, -}) - -L.FormBuilder.DataFormat = L.FormBuilder.Select.extend({ - selectOptions: [ - [undefined, L._('Choose the data format')], - ['geojson', 'geojson'], - ['osm', 'osm'], - ['csv', 'csv'], - ['gpx', 'gpx'], - ['kml', 'kml'], - ['georss', 'georss'], - ], -}) - -L.FormBuilder.LicenceChooser = L.FormBuilder.Select.extend({ - getOptions: function () { - const licences = [] - const licencesList = this.builder.obj.properties.licences - let licence - for (const i in licencesList) { - licence = licencesList[i] - licences.push([i, licence.name]) - } - return licences - }, - - toHTML: function () { - return this.get()?.name - }, - - toJS: function () { - return this.builder.obj.properties.licences[this.value()] - }, -}) - -L.FormBuilder.NullableBoolean = L.FormBuilder.Select.extend({ - selectOptions: [ - [undefined, L._('inherit')], - [true, L._('yes')], - [false, L._('no')], - ], - - toJS: function () { - let value = this.value() - switch (value) { - case 'true': - case true: - value = true - break - case 'false': - case false: - value = false - break - default: - value = undefined - } - return value - }, -}) - -L.FormBuilder.BlurInput.include({ - build: function () { - this.options.className = 'blur' - L.FormBuilder.Input.prototype.build.call(this) - const button = L.DomUtil.create('span', 'button blur-button') - L.DomUtil.after(this.input, button) - L.DomEvent.on(this.input, 'focus', this.fetch, this) - }, -}) - -// Adds an autocomplete using all available user defined properties -L.FormBuilder.PropertyInput = L.FormBuilder.BlurInput.extend({ - build: function () { - L.FormBuilder.BlurInput.prototype.build.call(this) - const autocomplete = new U.AutocompleteDatalist(this.input) - // Will be used on Umap and DataLayer - const properties = this.builder.obj.allProperties() - autocomplete.suggestions = properties - }, -}) - -L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({ - type: () => 'hidden', - - build: function () { - L.FormBuilder.BlurInput.prototype.build.call(this) - this.buttons = L.DomUtil.create('div', '', this.parentNode) - this.tabs = L.DomUtil.create('div', 'flat-tabs', this.parentNode) - this.body = L.DomUtil.create('div', 'umap-pictogram-body', this.parentNode) - this.footer = L.DomUtil.create('div', '', this.parentNode) - this.updatePreview() - this.on('define', this.onDefine) - }, - - onDefine: async function () { - this.buttons.innerHTML = '' - this.footer.innerHTML = '' - const [{ pictogram_list }, response, error] = await this.builder._umap.server.get( - this.builder._umap.properties.urls.pictogram_list_json - ) - if (!error) this.pictogram_list = pictogram_list - this.buildTabs() - const value = this.value() - if (U.Icon.RECENT.length) this.showRecentTab() - else if (!value || U.Utils.isPath(value)) this.showSymbolsTab() - else if (U.Utils.isRemoteUrl(value) || U.Utils.isDataImage(value)) this.showURLTab() - else this.showCharsTab() - const closeButton = L.DomUtil.createButton( - 'button action-button', - this.footer, - L._('Close'), - function (e) { - this.body.innerHTML = '' - this.tabs.innerHTML = '' - this.footer.innerHTML = '' - if (this.isDefault()) this.undefine(e) - else this.updatePreview() - }, - this - ) - }, - - buildTabs: function () { - this.tabs.innerHTML = '' - if (U.Icon.RECENT.length) { - const recent = L.DomUtil.add( - 'button', - 'flat tab-recent', - this.tabs, - L._('Recent') - ) - L.DomEvent.on(recent, 'click', L.DomEvent.stop).on( - recent, - 'click', - this.showRecentTab, - this - ) - } - const symbol = L.DomUtil.add('button', 'flat tab-symbols', this.tabs, L._('Symbol')) - const char = L.DomUtil.add( - 'button', - 'flat tab-chars', - this.tabs, - L._('Emoji & Character') - ) - url = L.DomUtil.add('button', 'flat tab-url', this.tabs, L._('URL')) - L.DomEvent.on(symbol, 'click', L.DomEvent.stop).on( - symbol, - 'click', - this.showSymbolsTab, - this - ) - L.DomEvent.on(char, 'click', L.DomEvent.stop).on( - char, - 'click', - this.showCharsTab, - this - ) - L.DomEvent.on(url, 'click', L.DomEvent.stop).on(url, 'click', this.showURLTab, this) - }, - - openTab: function (name) { - const els = this.tabs.querySelectorAll('button') - for (const el of els) { - L.DomUtil.removeClass(el, 'on') - } - const el = this.tabs.querySelector(`.tab-${name}`) - L.DomUtil.addClass(el, 'on') - this.body.innerHTML = '' - }, - - updatePreview: function () { - this.buttons.innerHTML = '' - if (this.isDefault()) return - if (!U.Utils.hasVar(this.value())) { - // Do not try to render URL with variables - const box = L.DomUtil.create('div', 'umap-pictogram-choice', this.buttons) - L.DomEvent.on(box, 'click', this.onDefine, this) - const icon = U.Icon.makeElement(this.value(), box) - } - this.button = L.DomUtil.createButton( - 'button action-button', - this.buttons, - this.value() ? L._('Change') : L._('Add'), - this.onDefine, - this - ) - }, - - addIconPreview: function (pictogram, parent) { - const baseClass = 'umap-pictogram-choice' - const value = pictogram.src - const search = U.Utils.normalize(this.searchInput.value) - const title = pictogram.attribution - ? `${pictogram.name} — © ${pictogram.attribution}` - : pictogram.name || pictogram.src - if (search && U.Utils.normalize(title).indexOf(search) === -1) return - const className = value === this.value() ? `${baseClass} selected` : baseClass - const container = L.DomUtil.create('div', className, parent) - U.Icon.makeElement(value, container) - container.title = title - L.DomEvent.on( - container, - 'click', - function (e) { - this.input.value = value - this.sync() - this.unselectAll(this.grid) - L.DomUtil.addClass(container, 'selected') - }, - this - ) - return true // Icon has been added (not filtered) - }, - - clear: function () { - this.input.value = '' - this.unselectAll(this.body) - this.sync() - this.body.innerHTML = '' - this.updatePreview() - }, - - addCategory: function (items, name) { - const parent = L.DomUtil.create('div', 'umap-pictogram-category') - if (name) L.DomUtil.add('h6', '', parent, name) - const grid = L.DomUtil.create('div', 'umap-pictogram-grid', parent) - let status = false - for (const item of items) { - status = this.addIconPreview(item, grid) || status - } - if (status) this.grid.appendChild(parent) - }, - - buildSymbolsList: function () { - this.grid.innerHTML = '' - const categories = {} - let category - for (const props of this.pictogram_list) { - category = props.category || L._('Generic') - categories[category] = categories[category] || [] - categories[category].push(props) - } - const sorted = Object.entries(categories).toSorted(([a], [b]) => - U.Utils.naturalSort(a, b, U.lang) - ) - for (const [name, items] of sorted) { - this.addCategory(items, name) - } - }, - - buildRecentList: function () { - this.grid.innerHTML = '' - const items = U.Icon.RECENT.map((src) => ({ - src, - })) - this.addCategory(items) - }, - - isDefault: function () { - return !this.value() || this.value() === U.SCHEMA.iconUrl.default - }, - - addGrid: function (onSearch) { - this.searchInput = L.DomUtil.create('input', '', this.body) - this.searchInput.type = 'search' - this.searchInput.placeholder = L._('Search') - this.grid = L.DomUtil.create('div', '', this.body) - L.DomEvent.on(this.searchInput, 'input', onSearch, this) - }, - - showRecentTab: function () { - if (!U.Icon.RECENT.length) return - this.openTab('recent') - this.addGrid(this.buildRecentList) - this.buildRecentList() - }, - - showSymbolsTab: function () { - this.openTab('symbols') - this.addGrid(this.buildSymbolsList) - this.buildSymbolsList() - }, - - showCharsTab: function () { - this.openTab('chars') - const value = !U.Icon.isImg(this.value()) ? this.value() : null - const input = this.buildInput(this.body, value) - input.placeholder = L._('Type char or paste emoji') - input.type = 'text' - }, - - showURLTab: function () { - this.openTab('url') - const value = - U.Utils.isRemoteUrl(this.value()) || U.Utils.isDataImage(this.value()) - ? this.value() - : null - const input = this.buildInput(this.body, value) - input.placeholder = L._('Add image URL') - input.type = 'url' - }, - - buildInput: function (parent, value) { - const input = L.DomUtil.create('input', 'blur', parent) - const button = L.DomUtil.create('span', 'button blur-button', parent) - if (value) input.value = value - L.DomEvent.on(input, 'blur', () => { - // Do not clear this.input when focus-blur - // empty input - if (input.value === value) return - this.input.value = input.value - this.sync() - }) - return input - }, - - unselectAll: (container) => { - const els = container.querySelectorAll('div.selected') - for (const el in els) { - if (els.hasOwnProperty(el)) L.DomUtil.removeClass(els[el], 'selected') - } - }, -}) - -L.FormBuilder.Url = L.FormBuilder.Input.extend({ - type: () => 'url', -}) - -L.FormBuilder.Switch = L.FormBuilder.CheckBox.extend({ - getParentNode: function () { - L.FormBuilder.CheckBox.prototype.getParentNode.call(this) - if (this.options.inheritable) return this.quickContainer - return this.extendedContainer - }, - - build: function () { - L.FormBuilder.CheckBox.prototype.build.apply(this) - if (this.options.inheritable) - this.label = L.DomUtil.create('label', '', this.input.parentNode) - else this.input.parentNode.appendChild(this.label) - L.DomUtil.addClass(this.input.parentNode, 'with-switch') - const id = `${this.builder.options.id || Date.now()}.${this.name}` - this.label.setAttribute('for', id) - L.DomUtil.addClass(this.input, 'switch') - this.input.id = id - }, -}) - -L.FormBuilder.FacetSearchBase = L.FormBuilder.Element.extend({ - buildLabel: function () { - this.label = L.DomUtil.element({ - tagName: 'legend', - textContent: this.options.label, - }) - }, -}) -L.FormBuilder.FacetSearchChoices = L.FormBuilder.FacetSearchBase.extend({ - build: function () { - this.container = L.DomUtil.create('fieldset', 'umap-facet', this.parentNode) - this.container.appendChild(this.label) - this.ul = L.DomUtil.create('ul', '', this.container) - this.type = this.options.criteria.type - - const choices = this.options.criteria.choices - choices.sort() - choices.forEach((value) => this.buildLi(value)) - }, - - buildLi: function (value) { - const property_li = L.DomUtil.create('li', '', this.ul) - const label = L.DomUtil.create('label', '', property_li) - const input = L.DomUtil.create('input', '', label) - L.DomUtil.add('span', '', label, value) - - input.type = this.type - input.name = `${this.type}_${this.name}` - input.checked = this.get().choices.includes(value) - input.dataset.value = value - - L.DomEvent.on(input, 'change', (e) => this.sync()) - }, - - toJS: function () { - return { - type: this.type, - choices: [...this.ul.querySelectorAll('input:checked')].map( - (i) => i.dataset.value - ), - } - }, -}) - -L.FormBuilder.MinMaxBase = L.FormBuilder.FacetSearchBase.extend({ - getInputType: (type) => type, - - getLabels: () => [L._('Min'), L._('Max')], - - prepareForHTML: (value) => value.valueOf(), - - build: function () { - this.container = L.DomUtil.create('fieldset', 'umap-facet', this.parentNode) - this.container.appendChild(this.label) - const { min, max, type } = this.options.criteria - const { min: modifiedMin, max: modifiedMax } = this.get() - - const currentMin = modifiedMin !== undefined ? modifiedMin : min - const currentMax = modifiedMax !== undefined ? modifiedMax : max - this.type = type - this.inputType = this.getInputType(this.type) - - const [minLabel, maxLabel] = this.getLabels() - - this.minLabel = L.DomUtil.create('label', '', this.container) - this.minLabel.textContent = minLabel - - this.minInput = L.DomUtil.create('input', '', this.minLabel) - this.minInput.type = this.inputType - this.minInput.step = 'any' - this.minInput.min = this.prepareForHTML(min) - this.minInput.max = this.prepareForHTML(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) - } - - this.maxLabel = L.DomUtil.create('label', '', this.container) - this.maxLabel.textContent = maxLabel - - this.maxInput = L.DomUtil.create('input', '', this.maxLabel) - this.maxInput.type = this.inputType - this.maxInput.step = 'any' - this.maxInput.min = this.prepareForHTML(min) - this.maxInput.max = this.prepareForHTML(max) - 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() - - L.DomEvent.on(this.minInput, 'change', () => this.sync()) - L.DomEvent.on(this.maxInput, 'change', () => this.sync()) - }, - - toggleStatus: function () { - this.minInput.dataset.modified = this.isMinModified() - this.maxInput.dataset.modified = this.isMaxModified() - }, - - sync: function () { - L.FormBuilder.Element.prototype.sync.call(this) - this.toggleStatus() - }, - - isMinModified: function () { - const default_ = this.minInput.getAttribute('value') - const current = this.minInput.value - return current !== default_ - }, - - isMaxModified: function () { - const default_ = this.maxInput.getAttribute('value') - const current = this.maxInput.value - return current !== default_ - }, - - toJS: function () { - 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 - }, -}) - -L.FormBuilder.FacetSearchNumber = L.FormBuilder.MinMaxBase.extend({ - prepareForJS: (value) => new Number(value), -}) - -L.FormBuilder.FacetSearchDate = L.FormBuilder.MinMaxBase.extend({ - prepareForJS: (value) => new Date(value), - - toLocaleDateTime: (dt) => new Date(dt.valueOf() - dt.getTimezoneOffset() * 60000), - - prepareForHTML: function (value) { - // Value must be in local time - if (Number.isNaN(value)) return - return this.toLocaleDateTime(value).toISOString().substr(0, 10) - }, - - getLabels: () => [L._('From'), L._('Until')], -}) - -L.FormBuilder.FacetSearchDateTime = L.FormBuilder.FacetSearchDate.extend({ - getInputType: (type) => 'datetime-local', - - prepareForHTML: function (value) { - // Value must be in local time - if (Number.isNaN(value)) return - return this.toLocaleDateTime(value).toISOString().slice(0, -1) - }, -}) - -L.FormBuilder.MultiChoice = L.FormBuilder.Element.extend({ - default: 'null', - className: 'umap-multiplechoice', - - clear: function () { - const checked = this.container.querySelector('input[type="radio"]:checked') - if (checked) checked.checked = false - }, - - fetch: function () { - this.initial = this.toHTML() - let value = this.initial - if (!this.container.querySelector(`input[type="radio"][value="${value}"]`)) { - value = this.options.default !== undefined ? this.options.default : this.default - } - const choices = this.getChoices().map(([value, label]) => `${value}`) - if (choices.includes(`${value}`)) { - this.container.querySelector(`input[type="radio"][value="${value}"]`).checked = - true - } - }, - - value: function () { - const checked = this.container.querySelector('input[type="radio"]:checked') - if (checked) return checked.value - }, - - getChoices: function () { - return this.options.choices || this.choices - }, - - build: function () { - const choices = this.getChoices() - this.container = L.DomUtil.create( - 'div', - `${this.className} by${choices.length}`, - this.parentNode - ) - for (const [i, [value, label]] of choices.entries()) { - this.addChoice(value, label, i) - } - this.fetch() - }, - - addChoice: function (value, label, counter) { - const input = L.DomUtil.create('input', '', this.container) - label = L.DomUtil.add('label', '', this.container, label) - input.type = 'radio' - input.name = this.name - input.value = value - const id = `${Date.now()}.${this.name}.${counter}` - label.setAttribute('for', id) - input.id = id - L.DomEvent.on(input, 'change', this.sync, this) - }, -}) - -L.FormBuilder.TernaryChoices = L.FormBuilder.MultiChoice.extend({ - default: 'null', - - toJS: function () { - let value = this.value() - switch (value) { - case 'true': - case true: - value = true - break - case 'false': - case false: - value = false - break - case 'null': - case null: - value = null - break - default: - value = undefined - } - return value - }, -}) - -L.FormBuilder.NullableChoices = L.FormBuilder.TernaryChoices.extend({ - choices: [ - [true, L._('always')], - [false, L._('never')], - ['null', L._('hidden')], - ], -}) - -L.FormBuilder.DataLayersControl = L.FormBuilder.TernaryChoices.extend({ - choices: [ - [true, L._('collapsed')], - ['expanded', L._('expanded')], - [false, L._('never')], - ['null', L._('hidden')], - ], - - toJS: function () { - let value = this.value() - if (value !== 'expanded') - value = L.FormBuilder.TernaryChoices.prototype.toJS.call(this) - return value - }, -}) - -L.FormBuilder.Range = L.FormBuilder.FloatInput.extend({ - type: () => 'range', - - value: function () { - return L.DomUtil.hasClass(this.wrapper, 'undefined') - ? undefined - : L.FormBuilder.FloatInput.prototype.value.call(this) - }, - - buildHelpText: function () { - let options = '' - const step = this.options.step || 1 - const digits = step < 1 ? 1 : 0 - const id = `range-${this.options.label || this.name}` - for (let i = this.options.min; i <= this.options.max; i += this.options.step) { - options += `` - } - const datalist = L.DomUtil.element({ - tagName: 'datalist', - parent: this.getHelpTextParent(), - className: 'umap-field-datalist', - safeHTML: options, - id: id, - }) - this.input.setAttribute('list', id) - L.FormBuilder.Input.prototype.buildHelpText.call(this) - }, -}) - -L.FormBuilder.ManageOwner = L.FormBuilder.Element.extend({ - build: function () { - const options = { - className: 'edit-owner', - on_select: L.bind(this.onSelect, this), - placeholder: L._("Type new owner's username"), - } - this.autocomplete = new U.AjaxAutocomplete(this.parentNode, options) - const owner = this.toHTML() - if (owner) - this.autocomplete.displaySelected({ - item: { value: owner.id, label: owner.name }, - }) - }, - - value: function () { - return this._value - }, - - onSelect: function (choice) { - this._value = { - id: choice.item.value, - name: choice.item.label, - url: choice.item.url, - } - this.set() - }, -}) - -L.FormBuilder.ManageEditors = L.FormBuilder.Element.extend({ - build: function () { - const options = { - className: 'edit-editors', - on_select: L.bind(this.onSelect, this), - on_unselect: L.bind(this.onUnselect, this), - placeholder: L._("Type editor's username"), - } - this.autocomplete = new U.AjaxAutocompleteMultiple(this.parentNode, options) - this._values = this.toHTML() - if (this._values) - for (let i = 0; i < this._values.length; i++) - this.autocomplete.displaySelected({ - item: { value: this._values[i].id, label: this._values[i].name }, - }) - }, - - value: function () { - return this._values - }, - - onSelect: function (choice) { - this._values.push({ - id: choice.item.value, - name: choice.item.label, - url: choice.item.url, - }) - this.set() - }, - - onUnselect: function (choice) { - const index = this._values.findIndex((item) => item.id === choice.item.value) - if (index !== -1) { - this._values.splice(index, 1) - this.set() - } - }, -}) - -L.FormBuilder.ManageTeam = L.FormBuilder.IntSelect.extend({ - getOptions: function () { - return [[null, L._('None')]].concat( - this.options.teams.map((team) => [team.id, team.name]) - ) - }, - toHTML: function () { - return this.get()?.id - }, - toJS: function () { - const value = this.value() - for (const team of this.options.teams) { - if (team.id === value) return team - } - }, -}) - -U.FormBuilder = L.FormBuilder.extend({ - options: { - className: 'umap-form', - }, - - customHandlers: { - sortKey: 'PropertyInput', - easing: 'Switch', - facetKey: 'PropertyInput', - slugKey: 'PropertyInput', - labelKey: 'PropertyInput', - }, - - computeDefaultOptions: function () { - for (const [key, schema] of Object.entries(U.SCHEMA)) { - if (schema.type === Boolean) { - if (schema.nullable) schema.handler = 'NullableChoices' - else schema.handler = 'Switch' - } else if (schema.type === 'Text') { - schema.handler = 'Textarea' - } else if (schema.type === Number) { - if (schema.step) schema.handler = 'Range' - else schema.handler = 'IntInput' - } else if (schema.choices) { - const text_length = schema.choices.reduce( - (acc, [_, label]) => acc + label.length, - 0 - ) - // Try to be smart and use MultiChoice only - // for choices where labels are shorts… - if (text_length < 40) { - schema.handler = 'MultiChoice' - } else { - schema.handler = 'Select' - schema.selectOptions = schema.choices - } - } else { - switch (key) { - case 'color': - case 'fillColor': - schema.handler = 'ColorPicker' - break - case 'iconUrl': - schema.handler = 'IconUrl' - break - case 'licence': - schema.handler = 'LicenceChooser' - break - } - } - if (this.customHandlers[key]) { - schema.handler = this.customHandlers[key] - } - // FormBuilder use this key for the input type itself - delete schema.type - this.defaultOptions[key] = schema - } - }, - - initialize: function (obj, fields, options = {}) { - this._umap = obj._umap || options.umap - this.computeDefaultOptions() - L.FormBuilder.prototype.initialize.call(this, obj, fields, options) - this.on('finish', this.finish) - }, - - setter: function (field, value) { - L.FormBuilder.prototype.setter.call(this, field, value) - this.obj.isDirty = true - if ('render' in this.obj) { - this.obj.render([field], this) - } - if ('sync' in this.obj) { - this.obj.sync.update(field, value) - } - }, - - getter: function (field) { - const path = field.split('.') - let value = this.obj - let sub - for (sub of path) { - try { - value = value[sub] - } catch { - console.log(field) - } - } - if (value === undefined) values = U.SCHEMA[sub]?.default - return value - }, - - finish: (event) => { - event.helper?.input?.blur() - }, -}) diff --git a/umap/static/umap/vendors/formbuilder/Leaflet.FormBuilder.js b/umap/static/umap/vendors/formbuilder/Leaflet.FormBuilder.js deleted file mode 100644 index 6f814904e..000000000 --- a/umap/static/umap/vendors/formbuilder/Leaflet.FormBuilder.js +++ /dev/null @@ -1,468 +0,0 @@ -L.FormBuilder = L.Evented.extend({ - options: { - className: 'leaflet-form', - }, - - defaultOptions: { - // Eg.: - // name: {label: L._('name')}, - // description: {label: L._('description'), handler: 'Textarea'}, - // opacity: {label: L._('opacity'), helpText: L._('Opacity, from 0.1 to 1.0 (opaque).')}, - }, - - initialize: function (obj, fields, options) { - L.setOptions(this, options) - this.obj = obj - this.form = L.DomUtil.create('form', this.options.className) - this.setFields(fields) - if (this.options.id) { - this.form.id = this.options.id - } - if (this.options.className) { - L.DomUtil.addClass(this.form, this.options.className) - } - }, - - setFields: function (fields) { - this.fields = fields || [] - this.helpers = {} - }, - - build: function () { - this.form.innerHTML = '' - for (const idx in this.fields) { - this.buildField(this.fields[idx]) - } - this.on('postsync', this.onPostSync) - return this.form - }, - - buildField: function (field) { - // field can be either a string like "option.name" or a full definition array, - // like ['options.tilelayer.tms', {handler: 'CheckBox', helpText: 'TMS format'}] - let type - let helper - let options - if (Array.isArray(field)) { - options = field[1] || {} - field = field[0] - } else { - options = this.defaultOptions[this.getName(field)] || {} - } - type = options.handler || 'Input' - if (typeof type === 'string' && L.FormBuilder[type]) { - helper = new L.FormBuilder[type](this, field, options) - } else { - helper = new type(this, field, options) - } - this.helpers[field] = helper - return helper - }, - - getter: function (field) { - const path = field.split('.') - let value = this.obj - for (const sub of path) { - try { - value = value[sub] - } catch { - console.log(field) - } - } - return value - }, - - setter: function (field, value) { - const path = field.split('.') - let obj = this.obj - let what - for (let i = 0, l = path.length; i < l; i++) { - what = path[i] - if (what === path[l - 1]) { - if (typeof value === 'undefined') { - delete obj[what] - } else { - obj[what] = value - } - } else { - obj = obj[what] - } - } - }, - - restoreField: function (field) { - const initial = this.helpers[field].initial - this.setter(field, initial) - }, - - getName: (field) => { - const fieldEls = field.split('.') - return fieldEls[fieldEls.length - 1] - }, - - fetchAll: function () { - for (const helper of Object.values(this.helpers)) { - helper.fetch() - } - }, - - syncAll: function () { - for (const helper of Object.values(this.helpers)) { - helper.sync() - } - }, - - onPostSync: function (e) { - if (e.helper.options.callback) { - e.helper.options.callback.call(e.helper.options.callbackContext || this.obj, e) - } - if (this.options.callback) { - this.options.callback.call(this.options.callbackContext || this.obj, e) - } - }, -}) - -L.FormBuilder.Element = L.Evented.extend({ - initialize: function (builder, field, options) { - this.builder = builder - this.obj = this.builder.obj - this.form = this.builder.form - this.field = field - L.setOptions(this, options) - this.fieldEls = this.field.split('.') - this.name = this.builder.getName(field) - this.parentNode = this.getParentNode() - this.buildLabel() - this.build() - this.buildHelpText() - this.fireAndForward('helper:init') - }, - - fireAndForward: function (type, e = {}) { - e.helper = this - this.fire(type, e) - this.builder.fire(type, e) - if (this.obj.fire) this.obj.fire(type, e) - }, - - getParentNode: function () { - return this.options.wrapper - ? L.DomUtil.create( - this.options.wrapper, - this.options.wrapperClass || '', - this.form - ) - : this.form - }, - - get: function () { - return this.builder.getter(this.field) - }, - - toHTML: function () { - return this.get() - }, - - toJS: function () { - return this.value() - }, - - sync: function () { - this.fireAndForward('presync') - this.set() - this.fireAndForward('postsync') - }, - - set: function () { - this.builder.setter(this.field, this.toJS()) - }, - - getLabelParent: function () { - return this.parentNode - }, - - getHelpTextParent: function () { - return this.parentNode - }, - - buildLabel: function () { - if (this.options.label) { - this.label = L.DomUtil.create('label', '', this.getLabelParent()) - this.label.innerHTML = this.options.label - } - }, - - buildHelpText: function () { - if (this.options.helpText) { - const container = L.DomUtil.create('small', 'help-text', this.getHelpTextParent()) - container.innerHTML = this.options.helpText - } - }, - - fetch: () => {}, - - finish: function () { - this.fireAndForward('finish') - }, -}) - -L.FormBuilder.Textarea = L.FormBuilder.Element.extend({ - build: function () { - this.input = L.DomUtil.create( - 'textarea', - this.options.className || '', - this.parentNode - ) - if (this.options.placeholder) this.input.placeholder = this.options.placeholder - this.fetch() - L.DomEvent.on(this.input, 'input', this.sync, this) - L.DomEvent.on(this.input, 'keypress', this.onKeyPress, this) - }, - - fetch: function () { - const value = this.toHTML() - this.initial = value - if (value) { - this.input.value = value - } - }, - - value: function () { - return this.input.value - }, - - onKeyPress: function (e) { - if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey)) { - L.DomEvent.stop(e) - this.finish() - } - }, -}) - -L.FormBuilder.Input = L.FormBuilder.Element.extend({ - build: function () { - this.input = L.DomUtil.create( - 'input', - this.options.className || '', - this.parentNode - ) - this.input.type = this.type() - this.input.name = this.name - this.input._helper = this - if (this.options.placeholder) { - this.input.placeholder = this.options.placeholder - } - if (this.options.min !== undefined) { - this.input.min = this.options.min - } - if (this.options.max !== undefined) { - this.input.max = this.options.max - } - if (this.options.step) { - this.input.step = this.options.step - } - this.fetch() - L.DomEvent.on(this.input, this.getSyncEvent(), this.sync, this) - L.DomEvent.on(this.input, 'keydown', this.onKeyDown, this) - }, - - fetch: function () { - const value = this.toHTML() !== undefined ? this.toHTML() : null - this.initial = value - this.input.value = value - }, - - getSyncEvent: () => 'input', - - type: function () { - return this.options.type || 'text' - }, - - value: function () { - return this.input.value || undefined - }, - - onKeyDown: function (e) { - if (e.key === 'Enter') { - L.DomEvent.stop(e) - this.finish() - } - }, -}) - -L.FormBuilder.BlurInput = L.FormBuilder.Input.extend({ - getSyncEvent: () => 'blur', - - build: function () { - L.FormBuilder.Input.prototype.build.call(this) - L.DomEvent.on(this.input, 'focus', this.fetch, this) - }, - - finish: function () { - this.sync() - L.FormBuilder.Input.prototype.finish.call(this) - }, - - sync: function () { - // Do not commit any change if user only clicked - // on the field than clicked outside - if (this.initial !== this.value()) { - L.FormBuilder.Input.prototype.sync.call(this) - } - }, -}) - -L.FormBuilder.IntegerMixin = { - value: function () { - return !isNaN(this.input.value) && this.input.value !== '' - ? parseInt(this.input.value, 10) - : undefined - }, - - type: () => 'number', -} - -L.FormBuilder.IntInput = L.FormBuilder.Input.extend({ - includes: [L.FormBuilder.IntegerMixin], -}) - -L.FormBuilder.BlurIntInput = L.FormBuilder.BlurInput.extend({ - includes: [L.FormBuilder.IntegerMixin], -}) - -L.FormBuilder.FloatMixin = { - value: function () { - return !isNaN(this.input.value) && this.input.value !== '' - ? parseFloat(this.input.value) - : undefined - }, - - type: () => 'number', -} - -L.FormBuilder.FloatInput = L.FormBuilder.Input.extend({ - options: { - step: 'any', - }, - - includes: [L.FormBuilder.FloatMixin], -}) - -L.FormBuilder.BlurFloatInput = L.FormBuilder.BlurInput.extend({ - options: { - step: 'any', - }, - - includes: [L.FormBuilder.FloatMixin], -}) - -L.FormBuilder.CheckBox = L.FormBuilder.Element.extend({ - build: function () { - const container = L.DomUtil.create('div', 'checkbox-wrapper', this.parentNode) - this.input = L.DomUtil.create('input', this.options.className || '', container) - this.input.type = 'checkbox' - this.input.name = this.name - this.input._helper = this - this.fetch() - L.DomEvent.on(this.input, 'change', this.sync, this) - }, - - fetch: function () { - this.initial = this.toHTML() - this.input.checked = this.initial === true - }, - - value: function () { - return this.input.checked - }, - - toHTML: function () { - return [1, true].indexOf(this.get()) !== -1 - }, -}) - -L.FormBuilder.Select = L.FormBuilder.Element.extend({ - selectOptions: [['value', 'label']], - - build: function () { - this.select = L.DomUtil.create('select', '', this.parentNode) - this.select.name = this.name - this.validValues = [] - this.buildOptions() - L.DomEvent.on(this.select, 'change', this.sync, this) - }, - - getOptions: function () { - return this.options.selectOptions || this.selectOptions - }, - - fetch: function () { - this.buildOptions() - }, - - buildOptions: function () { - this.select.innerHTML = '' - for (const option of this.getOptions()) { - if (typeof option === 'string') this.buildOption(option, option) - else this.buildOption(option[0], option[1]) - } - }, - - buildOption: function (value, label) { - this.validValues.push(value) - const option = L.DomUtil.create('option', '', this.select) - option.value = value - option.innerHTML = label - if (this.toHTML() === value) { - option.selected = 'selected' - } - }, - - value: function () { - if (this.select[this.select.selectedIndex]) - return this.select[this.select.selectedIndex].value - }, - - getDefault: function () { - return this.getOptions()[0][0] - }, - - toJS: function () { - const value = this.value() - if (this.validValues.indexOf(value) !== -1) { - return value - } - return this.getDefault() - }, -}) - -L.FormBuilder.IntSelect = L.FormBuilder.Select.extend({ - value: function () { - return parseInt(L.FormBuilder.Select.prototype.value.apply(this), 10) - }, -}) - -L.FormBuilder.NullableBoolean = L.FormBuilder.Select.extend({ - selectOptions: [ - [undefined, 'inherit'], - [true, 'yes'], - [false, 'no'], - ], - - toJS: function () { - let value = this.value() - switch (value) { - case 'true': - case true: - value = true - break - case 'false': - case false: - value = false - break - default: - value = undefined - } - return value - }, -}) diff --git a/umap/templates/umap/js.html b/umap/templates/umap/js.html index f6aca61ef..974739315 100644 --- a/umap/templates/umap/js.html +++ b/umap/templates/umap/js.html @@ -30,8 +30,6 @@ - @@ -40,7 +38,6 @@ - {% endautoescape %} From e0fadea74902312b140bbdae269c9851c70b2ff2 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 6 Jan 2025 20:10:20 +0100 Subject: [PATCH 2/8] chore: remove Leaflet dependency from form modules --- umap/static/umap/js/modules/data/features.js | 13 +- umap/static/umap/js/modules/data/layer.js | 20 +- umap/static/umap/js/modules/form/builder.js | 29 +- umap/static/umap/js/modules/form/fields.js | 468 ++++++++++--------- umap/static/umap/js/modules/permissions.js | 9 +- umap/static/umap/js/modules/rules.js | 3 +- umap/static/umap/js/modules/share.js | 3 +- umap/static/umap/js/modules/tableeditor.js | 3 +- umap/static/umap/js/modules/umap.js | 26 +- umap/tests/integration/test_edit_map.py | 4 +- 10 files changed, 291 insertions(+), 287 deletions(-) diff --git a/umap/static/umap/js/modules/data/features.js b/umap/static/umap/js/modules/data/features.js index 0035f5302..707398041 100644 --- a/umap/static/umap/js/modules/data/features.js +++ b/umap/static/umap/js/modules/data/features.js @@ -16,6 +16,7 @@ import { MaskPolygon, } from '../rendering/ui.js' import loadPopup from '../rendering/popup.js' +import { MutatingForm } from '../form/builder.js' class Feature { constructor(umap, datalayer, geojson = {}, id = null) { @@ -225,7 +226,7 @@ class Feature { `icon-${this.getClassName()}` ) - let builder = new U.FormBuilder( + let builder = new MutatingForm( this, [['datalayer', { handler: 'DataLayerSwitcher' }]], { @@ -254,7 +255,7 @@ class Feature { labelKeyFound = U.DEFAULT_LABEL_KEY } properties.unshift([`properties.${labelKeyFound}`, { label: labelKeyFound }]) - builder = new U.FormBuilder(this, properties, { + builder = new MutatingForm(this, properties, { id: 'umap-feature-properties', }) container.appendChild(builder.build()) @@ -285,7 +286,7 @@ class Feature { appendEditFieldsets(container) { const optionsFields = this.getShapeOptions() - let builder = new U.FormBuilder(this, optionsFields, { + let builder = new MutatingForm(this, optionsFields, { id: 'umap-feature-shape-properties', }) const shapeProperties = DomUtil.createFieldset( @@ -295,7 +296,7 @@ class Feature { shapeProperties.appendChild(builder.build()) const advancedOptions = this.getAdvancedOptions() - builder = new U.FormBuilder(this, advancedOptions, { + builder = new MutatingForm(this, advancedOptions, { id: 'umap-feature-advanced-properties', }) const advancedProperties = DomUtil.createFieldset( @@ -305,7 +306,7 @@ class Feature { advancedProperties.appendChild(builder.build()) const interactionOptions = this.getInteractionOptions() - builder = new U.FormBuilder(this, interactionOptions) + builder = new MutatingForm(this, interactionOptions) const popupFieldset = DomUtil.createFieldset( container, translate('Interaction options') @@ -733,7 +734,7 @@ export class Point extends Feature { ['ui._latlng.lat', { handler: 'FloatInput', label: translate('Latitude') }], ['ui._latlng.lng', { handler: 'FloatInput', label: translate('Longitude') }], ] - const builder = new U.FormBuilder(this, coordinatesOptions, { + const builder = new MutatingForm(this, coordinatesOptions, { callback: () => { if (!this.ui._latlng.isValid()) { Alert.error(translate('Invalid latitude or longitude')) diff --git a/umap/static/umap/js/modules/data/layer.js b/umap/static/umap/js/modules/data/layer.js index a6f77a671..bd36bd951 100644 --- a/umap/static/umap/js/modules/data/layer.js +++ b/umap/static/umap/js/modules/data/layer.js @@ -1,5 +1,3 @@ -// Uses U.FormBuilder not available as ESM - // FIXME: this module should not depend on Leaflet import { DomUtil, @@ -22,6 +20,7 @@ import { Point, LineString, Polygon } from './features.js' import TableEditor from '../tableeditor.js' import { ServerStored } from '../saving.js' import * as Schema from '../schema.js' +import { MutatingForm } from '../form/builder.js' export const LAYER_TYPES = [ DefaultLayer, @@ -668,10 +667,11 @@ export class DataLayer extends ServerStored { ], ] DomUtil.createTitle(container, translate('Layer properties'), 'icon-layers') - let builder = new U.FormBuilder(this, metadataFields, { - callback(e) { + let builder = new MutatingForm(this, metadataFields, { + callback: (helper) => { + console.log(helper) this._umap.onDataLayersChanged() - if (e.helper.field === 'options.type') { + if (helper.field === 'options.type') { this.edit() } }, @@ -681,7 +681,7 @@ export class DataLayer extends ServerStored { const layerOptions = this.layer.getEditableOptions() if (layerOptions.length) { - builder = new U.FormBuilder(this, layerOptions, { + builder = new MutatingForm(this, layerOptions, { id: 'datalayer-layer-properties', }) const layerProperties = DomUtil.createFieldset( @@ -704,7 +704,7 @@ export class DataLayer extends ServerStored { 'options.fillOpacity', ] - builder = new U.FormBuilder(this, shapeOptions, { + builder = new MutatingForm(this, shapeOptions, { id: 'datalayer-advanced-properties', }) const shapeProperties = DomUtil.createFieldset( @@ -721,7 +721,7 @@ export class DataLayer extends ServerStored { 'options.toZoom', ] - builder = new U.FormBuilder(this, optionsFields, { + builder = new MutatingForm(this, optionsFields, { id: 'datalayer-advanced-properties', }) const advancedProperties = DomUtil.createFieldset( @@ -740,7 +740,7 @@ export class DataLayer extends ServerStored { 'options.outlinkTarget', 'options.interactive', ] - builder = new U.FormBuilder(this, popupFields) + builder = new MutatingForm(this, popupFields) const popupFieldset = DomUtil.createFieldset( container, translate('Interaction options') @@ -796,7 +796,7 @@ export class DataLayer extends ServerStored { container, translate('Remote data') ) - builder = new U.FormBuilder(this, remoteDataFields) + builder = new MutatingForm(this, remoteDataFields) remoteDataContainer.appendChild(builder.build()) DomUtil.createButton( 'button umap-verify', diff --git a/umap/static/umap/js/modules/form/builder.js b/umap/static/umap/js/modules/form/builder.js index 86a7ac843..cb6d0446c 100644 --- a/umap/static/umap/js/modules/form/builder.js +++ b/umap/static/umap/js/modules/form/builder.js @@ -13,7 +13,7 @@ export class Form { this.form.id = this.properties.id } if (this.properties.className) { - this.form.classList.add(this.properties.className) + this.form.classList.add(...this.properties.className.split(' ')) } } @@ -108,14 +108,16 @@ export class Form { } } - onPostSync() { + onPostSync(helper) { if (this.properties.callback) { - this.properties.callback(this.obj) + this.properties.callback(helper) } } + + finish() {} } -export class DataForm extends Form { +export class MutatingForm extends Form { constructor(obj, fields, properties) { super(obj, fields, properties) this._umap = obj._umap || properties.umap @@ -188,22 +190,7 @@ export class DataForm extends Form { } } - getter(field) { - const path = field.split('.') - let value = this.obj - let sub - for (sub of path) { - try { - value = value[sub] - } catch { - console.log(field) - } - } - if (value === undefined) value = SCHEMA[sub]?.default - return value - } - - finish(event) { - event.helper?.input?.blur() + finish(helper) { + helper.input?.blur() } } diff --git a/umap/static/umap/js/modules/form/fields.js b/umap/static/umap/js/modules/form/fields.js index 9a4fe8676..3c183a751 100644 --- a/umap/static/umap/js/modules/form/fields.js +++ b/umap/static/umap/js/modules/form/fields.js @@ -5,6 +5,8 @@ import { AjaxAutocompleteMultiple, AutocompleteDatalist, } from '../autocomplete.js' +import { SCHEMA } from '../schema.js' +import * as Icon from '../rendering/icon.js' const Fields = {} @@ -35,8 +37,8 @@ class BaseElement { getParentNode() { const classNames = ['formbox'] if (this.properties.inheritable) { - classNames.push(inheritable) - if (this.get(true)) classNames.push('undefined') + classNames.push('inheritable') + if (this.get(true) === undefined) classNames.push('undefined') } classNames.push(`umap-field-${this.name}`) const [wrapper, { header, define, undefine, quickContainer, container }] = @@ -55,8 +57,8 @@ class BaseElement { this.form.appendChild(this.wrapper) if (this.properties.inheritable) { define.addEventListener('click', (event) => { - e.preventDefault() - e.stopPropagation() + event.preventDefault() + event.stopPropagation() this.fetch() this.onDefine() this.wrapper.classList.remove('undefined') @@ -80,7 +82,7 @@ class BaseElement { if (!this.properties.inheritable || own) return this.builder.getter(this.field) const path = this.field.split('.') const key = path[path.length - 1] - return this.obj.getOption(key) + return this.obj.getOption(key) || SCHEMA[key]?.default } toHTML() { @@ -110,13 +112,16 @@ class BaseElement { buildLabel() { if (this.properties.label) { - this.label = L.DomUtil.create('label', '', this.getLabelParent()) - this.label.textContent = this.label.title = this.properties.label + const label = this.properties.label + this.label = Utils.loadTemplate(``) + const parent = this.getLabelParent() + parent.appendChild(this.label) if (this.properties.helpEntries) { this.builder._umap.help.button(this.label, this.properties.helpEntries) } else if (this.properties.helpTooltip) { - const info = L.DomUtil.create('i', 'info', this.label) - L.DomEvent.on(info, 'mouseover', () => { + const info = Utils.loadTemplate('') + this.label.appendChild(info) + info.addEventListener('mouseover', () => { this.builder._umap.tooltip.open({ anchor: info, content: this.properties.helpTooltip, @@ -129,22 +134,23 @@ class BaseElement { buildHelpText() { if (this.properties.helpText) { - const container = L.DomUtil.create('small', 'help-text', this.getHelpTextParent()) - container.innerHTML = this.properties.helpText + const container = Utils.loadTemplate( + `${Utils.escapeHTML(this.properties.helpText)}` + ) + const parent = this.getHelpTextParent() + parent.appendChild(container) } } fetch() {} - finish() { - this.fireAndForward('finish') - } + finish() {} onPostSync() { if (this.properties.callback) { - this.properties.callback(this.obj) + this.properties.callback(this) } - this.builder.onPostSync() + this.builder.onPostSync(this) } undefine() { @@ -156,16 +162,15 @@ class BaseElement { Fields.Textarea = class extends BaseElement { build() { - this.input = L.DomUtil.create( - 'textarea', - this.properties.className || '', - this.parentNode - ) - if (this.properties.placeholder) + this.input = Utils.loadTemplate('') + if (this.properties.className) this.input.classList.add(this.properties.className) + if (this.properties.placeholder) { this.input.placeholder = this.properties.placeholder + } + this.parentNode.appendChild(this.input) this.fetch() - L.DomEvent.on(this.input, 'input', this.sync, this) - L.DomEvent.on(this.input, 'keypress', this.onKeyPress, this) + this.input.addEventListener('input', () => this.sync()) + this.input.addEventListener('keypress', (event) => this.onKeyPress(event)) } fetch() { @@ -180,9 +185,10 @@ Fields.Textarea = class extends BaseElement { return this.input.value } - onKeyPress(e) { - if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey)) { - L.DomEvent.stop(e) + onKeyPress(event) { + if (event.key === 'Enter' && (event.shiftKey || event.ctrlKey)) { + event.stopPropagation() + event.preventDefault() this.finish() } } @@ -190,14 +196,14 @@ Fields.Textarea = class extends BaseElement { Fields.Input = class extends BaseElement { build() { - this.input = L.DomUtil.create( - 'input', - this.properties.className || '', - this.parentNode - ) + this.input = Utils.loadTemplate('') + this.parentNode.appendChild(this.input) this.input.type = this.type() this.input.name = this.name this.input._helper = this + if (this.properties.className) { + this.input.classList.add(this.properties.className) + } if (this.properties.placeholder) { this.input.placeholder = this.properties.placeholder } @@ -211,8 +217,8 @@ Fields.Input = class extends BaseElement { this.input.step = this.properties.step } this.fetch() - L.DomEvent.on(this.input, this.getSyncEvent(), this.sync, this) - L.DomEvent.on(this.input, 'keydown', this.onKeyDown, this) + this.input.addEventListener(this.getSyncEvent(), () => this.sync()) + this.input.addEventListener('keydown', (event) => this.onKeyDown(event)) } fetch() { @@ -233,10 +239,12 @@ Fields.Input = class extends BaseElement { return this.input.value || undefined } - onKeyDown(e) { - if (e.key === 'Enter') { - L.DomEvent.stop(e) + onKeyDown(event) { + if (event.key === 'Enter') { + event.stopPropagation() + event.preventDefault() this.finish() + this.input.blur() } } } @@ -249,8 +257,8 @@ Fields.BlurInput = class extends Fields.Input { build() { this.properties.className = 'blur' super.build() - const button = L.DomUtil.create('span', 'button blur-button') - L.DomUtil.after(this.input, button) + const button = Utils.loadTemplate('') + this.input.parentNode.insertBefore(button, this.input.nextSibling) this.input.addEventListener('focus', () => this.fetch()) } @@ -312,7 +320,11 @@ Fields.CheckBox = class extends BaseElement { build() { const container = Utils.loadTemplate('
') this.parentNode.appendChild(container) - this.input = L.DomUtil.create('input', this.properties.className || '', container) + this.input = Utils.loadTemplate('') + container.appendChild(this.input) + if (this.properties.className) { + this.input.classList.add(this.properties.className) + } this.input.type = 'checkbox' this.input.name = this.name this.input._helper = this @@ -340,11 +352,11 @@ Fields.CheckBox = class extends BaseElement { Fields.Select = class extends BaseElement { build() { - this.select = L.DomUtil.create('select', '', this.parentNode) - this.select.name = this.name + this.select = Utils.loadTemplate(``) + this.parentNode.appendChild(this.select) this.validValues = [] this.buildOptions() - L.DomEvent.on(this.select, 'change', this.sync, this) + this.select.addEventListener('change', () => this.sync()) } getOptions() { @@ -365,7 +377,8 @@ Fields.Select = class extends BaseElement { buildOption(value, label) { this.validValues.push(value) - const option = L.DomUtil.create('option', '', this.select) + const option = Utils.loadTemplate('') + this.select.appendChild(option) option.value = value option.innerHTML = label if (this.toHTML() === value) { @@ -374,8 +387,9 @@ Fields.Select = class extends BaseElement { } value() { - if (this.select[this.select.selectedIndex]) + if (this.select[this.select.selectedIndex]) { return this.select[this.select.selectedIndex].value + } } getDefault() { @@ -431,15 +445,14 @@ Fields.NullableBoolean = class extends Fields.Select { Fields.EditableText = class extends BaseElement { build() { - this.input = L.DomUtil.create( - 'span', - this.properties.className || '', - this.parentNode + this.input = Utils.loadTemplate( + `` ) + this.parentNode.appendChild(this.input) this.input.contentEditable = true this.fetch() - L.DomEvent.on(this.input, 'input', this.sync, this) - L.DomEvent.on(this.input, 'keypress', this.onKeyPress, this) + this.input.addEventListener('input', () => this.sync()) + this.input.addEventListener('keypress', (event) => this.onKeyPress(event)) } getParentNode() { @@ -475,21 +488,21 @@ Fields.ColorPicker = class extends Fields.Input { build() { super.build() this.input.placeholder = this.properties.placeholder || translate('Inherit') - this.container = L.DomUtil.create( - 'div', - 'umap-color-picker', - this.extendedContainer - ) + this.container = Utils.loadTemplate('
') + this.extendedContainer.appendChild(this.container) this.container.style.display = 'none' - for (const idx in this.colors) { - this.addColor(this.colors[idx]) + for (const color of this.getColors()) { + this.addColor(color) } this.spreadColor() this.input.autocomplete = 'off' - L.DomEvent.on(this.input, 'focus', this.onFocus, this) - L.DomEvent.on(this.input, 'blur', this.onBlur, this) - L.DomEvent.on(this.input, 'change', this.sync, this) - this.on('define', this.onFocus) + this.input.addEventListener('focus', (event) => this.onFocus(event)) + this.input.addEventListener('blur', (event) => this.onBlur(event)) + this.input.addEventListener('change', () => this.sync()) + } + + onDefine() { + this.onFocus() } onFocus() { @@ -516,14 +529,15 @@ Fields.ColorPicker = class extends Fields.Input { } addColor(colorName) { - const span = L.DomUtil.create('span', '', this.container) + const span = Utils.loadTemplate('') + this.container.appendChild(span) span.style.backgroundColor = span.title = colorName - const updateColorInput = function () { + const updateColorInput = () => { this.input.value = colorName this.sync() this.container.style.display = 'none' } - L.DomEvent.on(span, 'mousedown', updateColorInput, this) + span.addEventListener('mousedown', updateColorInput) } } @@ -668,12 +682,20 @@ Fields.IconUrl = class extends Fields.BlurInput { build() { super.build() - this.buttons = L.DomUtil.create('div', '', this.parentNode) - this.tabs = L.DomUtil.create('div', 'flat-tabs', this.parentNode) - this.body = L.DomUtil.create('div', 'umap-pictogram-body', this.parentNode) - this.footer = L.DomUtil.create('div', '', this.parentNode) + const [container, { buttons, tabs, body, footer }] = Utils.loadTemplateWithRefs(` +
+
+
+
+
+
+ `) + this.parentNode.appendChild(container) + this.buttons = buttons + this.tabs = tabs + this.body = body + this.footer = footer this.updatePreview() - this.on('define', this.onDefine) } async onDefine() { @@ -689,72 +711,64 @@ Fields.IconUrl = class extends Fields.BlurInput { else if (!value || Utils.isPath(value)) this.showSymbolsTab() else if (Utils.isRemoteUrl(value) || Utils.isDataImage(value)) this.showURLTab() else this.showCharsTab() - const closeButton = L.DomUtil.createButton( - 'button action-button', - this.footer, - translate('Close'), - function (e) { - this.body.innerHTML = '' - this.tabs.innerHTML = '' - this.footer.innerHTML = '' - if (this.isDefault()) this.undefine(e) - else this.updatePreview() - }, - this + const closeButton = Utils.loadTemplate( + `` ) + closeButton.addEventListener('click', () => { + this.body.innerHTML = '' + this.tabs.innerHTML = '' + this.footer.innerHTML = '' + if (this.isDefault()) this.undefine() + else this.updatePreview() + }) + this.footer.appendChild(closeButton) } buildTabs() { this.tabs.innerHTML = '' - if (U.Icon.RECENT.length) { - const recent = L.DomUtil.add( - 'button', - 'flat tab-recent', - this.tabs, - translate('Recent') - ) - L.DomEvent.on(recent, 'click', L.DomEvent.stop).on( - recent, - 'click', - this.showRecentTab, - this - ) + // Useless div, but loadTemplate needs a root element + const [root, { recent, symbols, chars, url }] = Utils.loadTemplateWithRefs(` +
+ + + + +
+ `) + this.tabs.appendChild(root) + if (Icon.RECENT.length) { + recent.addEventListener('click', (event) => { + event.stopPropagation() + event.preventDefault() + this.showRecentTab() + }) + } else { + recent.hidden = true } - const symbol = L.DomUtil.add( - 'button', - 'flat tab-symbols', - this.tabs, - translate('Symbol') - ) - const char = L.DomUtil.add( - 'button', - 'flat tab-chars', - this.tabs, - translate('Emoji & Character') - ) - url = L.DomUtil.add('button', 'flat tab-url', this.tabs, translate('URL')) - L.DomEvent.on(symbol, 'click', L.DomEvent.stop).on( - symbol, - 'click', - this.showSymbolsTab, - this - ) - L.DomEvent.on(char, 'click', L.DomEvent.stop).on( - char, - 'click', - this.showCharsTab, - this - ) - L.DomEvent.on(url, 'click', L.DomEvent.stop).on(url, 'click', this.showURLTab, this) + symbols.addEventListener('click', (event) => { + event.stopPropagation() + event.preventDefault() + this.showSymbolsTab() + }) + chars.addEventListener('click', (event) => { + event.stopPropagation() + event.preventDefault() + this.showCharsTab() + }) + url.addEventListener('click', (event) => { + event.stopPropagation() + event.preventDefault() + this.showURLTab() + }) } openTab(name) { const els = this.tabs.querySelectorAll('button') for (const el of els) { - L.DomUtil.removeClass(el, 'on') + el.classList.remove('on') } const el = this.tabs.querySelector(`.tab-${name}`) - L.DomUtil.addClass(el, 'on') + el.classList.add('on') this.body.innerHTML = '' } @@ -763,17 +777,17 @@ Fields.IconUrl = class extends Fields.BlurInput { if (this.isDefault()) return if (!Utils.hasVar(this.value())) { // Do not try to render URL with variables - const box = L.DomUtil.create('div', 'umap-pictogram-choice', this.buttons) - L.DomEvent.on(box, 'click', this.onDefine, this) - const icon = U.Icon.makeElement(this.value(), box) + const box = Utils.loadTemplate('
') + this.buttons.appendChild(box) + box.addEventListener('click', () => this.onDefine()) + const icon = Icon.makeElement(this.value(), box) } - this.button = L.DomUtil.createButton( - 'button action-button', - this.buttons, - this.value() ? translate('Change') : translate('Add'), - this.onDefine, - this + const text = this.value() ? translate('Change') : translate('Add') + const button = Utils.loadTemplate( + `` ) + button.addEventListener('click', () => this.onDefine()) + this.buttons.appendChild(button) } addIconPreview(pictogram, parent) { @@ -785,20 +799,17 @@ Fields.IconUrl = class extends Fields.BlurInput { : pictogram.name || pictogram.src if (search && Utils.normalize(title).indexOf(search) === -1) return const className = value === this.value() ? `${baseClass} selected` : baseClass - const container = L.DomUtil.create('div', className, parent) - U.Icon.makeElement(value, container) - container.title = title - L.DomEvent.on( - container, - 'click', - function (e) { - this.input.value = value - this.sync() - this.unselectAll(this.grid) - L.DomUtil.addClass(container, 'selected') - }, - this + const container = Utils.loadTemplate( + `
` ) + parent.appendChild(container) + Icon.makeElement(value, container) + container.addEventListener('click', () => { + this.input.value = value + this.sync() + this.unselectAll(this.grid) + container.classList.add('selected') + }) return true // Icon has been added (not filtered) } @@ -811,14 +822,17 @@ Fields.IconUrl = class extends Fields.BlurInput { } addCategory(items, name) { - const parent = L.DomUtil.create('div', 'umap-pictogram-category') - if (name) L.DomUtil.add('h6', '', parent, name) - const grid = L.DomUtil.create('div', 'umap-pictogram-grid', parent) - let status = false + const [parent, { grid }] = Utils.loadTemplateWithRefs(` +
+ +
+
+ `) + let hasIcons = false for (const item of items) { - status = this.addIconPreview(item, grid) || status + hasIcons = this.addIconPreview(item, grid) || hasIcons } - if (status) this.grid.appendChild(parent) + if (hasIcons) this.grid.appendChild(parent) } buildSymbolsList() { @@ -847,33 +861,35 @@ Fields.IconUrl = class extends Fields.BlurInput { } isDefault() { - return !this.value() || this.value() === U.SCHEMA.iconUrl.default + return !this.value() || this.value() === SCHEMA.iconUrl.default } addGrid(onSearch) { - this.searchInput = L.DomUtil.create('input', '', this.body) - this.searchInput.type = 'search' - this.searchInput.placeholder = translate('Search') - this.grid = L.DomUtil.create('div', '', this.body) - L.DomEvent.on(this.searchInput, 'input', onSearch, this) + this.searchInput = Utils.loadTemplate( + `` + ) + this.grid = Utils.loadTemplate('
') + this.body.appendChild(this.searchInput) + this.body.appendChild(this.grid) + this.searchInput.addEventListener('input', onSearch) } showRecentTab() { - if (!U.Icon.RECENT.length) return + if (!Icon.RECENT.length) return this.openTab('recent') - this.addGrid(this.buildRecentList) + this.addGrid(() => this.buildRecentList()) this.buildRecentList() } showSymbolsTab() { this.openTab('symbols') - this.addGrid(this.buildSymbolsList) + this.addGrid(() => this.buildSymbolsList()) this.buildSymbolsList() } showCharsTab() { this.openTab('chars') - const value = !U.Icon.isImg(this.value()) ? this.value() : null + const value = !Icon.isImg(this.value()) ? this.value() : null const input = this.buildInput(this.body, value) input.placeholder = translate('Type char or paste emoji') input.type = 'text' @@ -891,10 +907,12 @@ Fields.IconUrl = class extends Fields.BlurInput { } buildInput(parent, value) { - const input = L.DomUtil.create('input', 'blur', parent) - const button = L.DomUtil.create('span', 'button blur-button', parent) + const input = Utils.loadTemplate('') + const button = Utils.loadTemplate('') + parent.appendChild(input) + parent.appendChild(button) if (value) input.value = value - L.DomEvent.on(input, 'blur', () => { + input.addEventListener('blur', () => { // Do not clear this.input when focus-blur // empty input if (input.value === value) return @@ -926,33 +944,34 @@ Fields.Switch = class extends Fields.CheckBox { build() { super.build() - console.log(this) if (this.properties.inheritable) { this.label = Utils.loadTemplate('') } this.input.parentNode.appendChild(this.label) - L.DomUtil.addClass(this.input.parentNode, 'with-switch') + this.input.parentNode.classList.add('with-switch') const id = `${this.builder.properties.id || Date.now()}.${this.name}` this.label.setAttribute('for', id) - L.DomUtil.addClass(this.input, 'switch') + this.input.classList.add('switch') this.input.id = id } } Fields.FacetSearchBase = class extends BaseElement { - buildLabel() { - this.label = L.DomUtil.element({ - tagName: 'legend', - textContent: this.properties.label, - }) - } + buildLabel() {} } Fields.FacetSearchChoices = class extends Fields.FacetSearchBase { build() { - this.container = L.DomUtil.create('fieldset', 'umap-facet', this.parentNode) - this.container.appendChild(this.label) - this.ul = L.DomUtil.create('ul', '', this.container) + const [container, { ul, label }] = Utils.loadTemplateWithRefs(` +
+ ${Utils.escapeHTML(this.properties.label)} +
    +
    + `) + this.container = container + this.ul = ul + this.label = label + this.parentNode.appendChild(this.container) this.type = this.properties.criteria.type const choices = this.properties.criteria.choices @@ -961,17 +980,20 @@ Fields.FacetSearchChoices = class extends Fields.FacetSearchBase { } buildLi(value) { - const property_li = L.DomUtil.create('li', '', this.ul) - const label = L.DomUtil.create('label', '', property_li) - const input = L.DomUtil.create('input', '', label) - L.DomUtil.add('span', '', label, value) - - input.type = this.type - input.name = `${this.type}_${this.name}` + 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 - - L.DomEvent.on(input, 'change', (e) => this.sync()) + input.addEventListener('change', () => this.sync()) + this.ul.appendChild(li) } toJS() { @@ -998,26 +1020,27 @@ Fields.MinMaxBase = class extends Fields.FacetSearchBase { } build() { - this.container = L.DomUtil.create('fieldset', 'umap-facet', this.parentNode) - this.container.appendChild(this.label) + const [minLabel, maxLabel] = this.getLabels() 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 this.type = type - this.inputType = this.getInputType(this.type) - - const [minLabel, maxLabel] = this.getLabels() - - this.minLabel = L.DomUtil.create('label', '', this.container) - this.minLabel.textContent = minLabel - - this.minInput = L.DomUtil.create('input', '', this.minLabel) - this.minInput.type = this.inputType - this.minInput.step = 'any' - this.minInput.min = this.prepareForHTML(min) - this.minInput.max = this.prepareForHTML(max) + const inputType = this.getInputType(this.type) + const minHTML = this.prepareForHTML(min) + const maxHTML = this.prepareForHTML(max) + const [container, { minInput, maxInput }] = Utils.loadTemplateWithRefs(` +
    + ${Utils.escapeHTML(this.properties.label)} + + +
    + `) + this.container = container + this.minInput = minInput + this.maxInput = maxInput + this.parentNode.appendChild(this.container) if (min != null) { // The value stored using setAttribute is not modified by // user input, and will be used as initial value when calling @@ -1029,14 +1052,6 @@ Fields.MinMaxBase = class extends Fields.FacetSearchBase { this.minInput.value = this.prepareForHTML(currentMin) } - this.maxLabel = L.DomUtil.create('label', '', this.container) - this.maxLabel.textContent = maxLabel - - this.maxInput = L.DomUtil.create('input', '', this.maxLabel) - this.maxInput.type = this.inputType - this.maxInput.step = 'any' - this.maxInput.min = this.prepareForHTML(min) - this.maxInput.max = this.prepareForHTML(max) if (max != null) { // Cf comment above about setAttribute vs value this.maxInput.setAttribute('value', this.prepareForHTML(max)) @@ -1044,8 +1059,8 @@ Fields.MinMaxBase = class extends Fields.FacetSearchBase { } this.toggleStatus() - L.DomEvent.on(this.minInput, 'change', () => this.sync()) - L.DomEvent.on(this.maxInput, 'change', () => this.sync()) + this.minInput.addEventListener('change', () => this.sync()) + this.maxInput.addEventListener('change', () => this.sync()) } toggleStatus() { @@ -1126,6 +1141,7 @@ Fields.MultiChoice = class extends BaseElement { getDefault() { return 'null' } + // TODO: use public property when it's in our baseline getClassName() { return 'umap-multiplechoice' } @@ -1160,11 +1176,10 @@ Fields.MultiChoice = class extends BaseElement { build() { const choices = this.getChoices() - this.container = L.DomUtil.create( - 'div', - `${this.className} by${choices.length}`, - this.parentNode + this.container = Utils.loadTemplate( + `
    ` ) + this.parentNode.appendChild(this.container) for (const [i, [value, label]] of choices.entries()) { this.addChoice(value, label, i) } @@ -1172,15 +1187,15 @@ Fields.MultiChoice = class extends BaseElement { } addChoice(value, label, counter) { - const input = L.DomUtil.create('input', '', this.container) - label = L.DomUtil.add('label', '', this.container, label) - input.type = 'radio' - input.name = this.name - input.value = value const id = `${Date.now()}.${this.name}.${counter}` - label.setAttribute('for', id) - input.id = id - L.DomEvent.on(input, 'change', this.sync, this) + const input = Utils.loadTemplate( + `` + ) + this.container.appendChild(input) + this.container.appendChild( + Utils.loadTemplate(``) + ) + input.addEventListener('change', () => this.sync()) } } @@ -1261,13 +1276,10 @@ Fields.Range = class extends Fields.FloatInput { digits )}">` } - const datalist = L.DomUtil.element({ - tagName: 'datalist', - parent: this.getHelpTextParent(), - className: 'umap-field-datalist', - safeHTML: options, - id: id, - }) + const parent = this.getHelpTextParent() + const datalist = Utils.loadTemplate( + `${options}` + ) this.input.setAttribute('list', id) super.buildHelpText() } diff --git a/umap/static/umap/js/modules/permissions.js b/umap/static/umap/js/modules/permissions.js index b2c7650fe..3ece0029c 100644 --- a/umap/static/umap/js/modules/permissions.js +++ b/umap/static/umap/js/modules/permissions.js @@ -3,6 +3,7 @@ import { translate } from './i18n.js' import { uMapAlert as Alert } from '../components/alerts/alert.js' import { ServerStored } from './saving.js' import * as Utils from './utils.js' +import { MutatingForm } from './form/builder.js' // Dedicated object so we can deal with a separate dirty status, and thus // call the endpoint only when needed, saving one call at each save. @@ -58,7 +59,7 @@ export class MapPermissions extends ServerStored { selectOptions: this._umap.properties.share_statuses, }, ]) - const builder = new U.FormBuilder(this, fields) + const builder = new MutatingForm(this, fields) const form = builder.build() container.appendChild(form) @@ -133,7 +134,7 @@ export class MapPermissions extends ServerStored { { handler: 'ManageEditors', label: translate("Map's editors") }, ]) - const builder = new U.FormBuilder(this, topFields) + const builder = new MutatingForm(this, topFields) const form = builder.build() container.appendChild(form) if (collaboratorsFields.length) { @@ -141,7 +142,7 @@ export class MapPermissions extends ServerStored { `
    ${translate('Manage collaborators')}
    ` ) container.appendChild(fieldset) - const builder = new U.FormBuilder(this, collaboratorsFields) + const builder = new MutatingForm(this, collaboratorsFields) const form = builder.build() container.appendChild(form) } @@ -269,7 +270,7 @@ export class DataLayerPermissions extends ServerStored { }, ], ] - const builder = new U.FormBuilder(this, fields, { + const builder = new MutatingForm(this, fields, { className: 'umap-form datalayer-permissions', }) const form = builder.build() diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index f8c6dbb4f..2e79ece17 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -3,6 +3,7 @@ import { translate } from './i18n.js' import * as Utils from './utils.js' import { AutocompleteDatalist } from './autocomplete.js' import Orderable from './orderable.js' +import { MutatingForm } from './form/builder.js' const EMPTY_VALUES = ['', undefined, null] @@ -129,7 +130,7 @@ class Rule { 'options.dashArray', ] const container = DomUtil.create('div') - const builder = new U.FormBuilder(this, options) + const builder = new MutatingForm(this, options) const defaultShapeProperties = DomUtil.add('div', '', container) defaultShapeProperties.appendChild(builder.build()) const autocomplete = new AutocompleteDatalist(builder.helpers.condition.input) diff --git a/umap/static/umap/js/modules/share.js b/umap/static/umap/js/modules/share.js index 919656795..6a0a49cee 100644 --- a/umap/static/umap/js/modules/share.js +++ b/umap/static/umap/js/modules/share.js @@ -2,6 +2,7 @@ import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' import { EXPORT_FORMATS } from './formatter.js' import { translate } from './i18n.js' import * as Utils from './utils.js' +import { MutatingForm } from './form/builder.js' export default class Share { constructor(umap) { @@ -125,7 +126,7 @@ export default class Share { exportUrl.value = window.location.protocol + iframeExporter.buildUrl() } buildIframeCode() - const builder = new U.FormBuilder(iframeExporter, UIFields, { + const builder = new MutatingForm(iframeExporter, UIFields, { callback: buildIframeCode, }) const iframeOptions = DomUtil.createFieldset( diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index 0cabf37ae..255f26cbf 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -2,6 +2,7 @@ import { DomEvent, DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' import { translate } from './i18n.js' import ContextMenu from './ui/contextmenu.js' import { WithTemplate, loadTemplate } from './utils.js' +import { MutatingForm } from './form/builder.js' const TEMPLATE = ` @@ -205,7 +206,7 @@ export default class TableEditor extends WithTemplate { const tr = event.target.closest('tr') const feature = this.datalayer.getFeatureById(tr.dataset.feature) const handler = property === 'description' ? 'Textarea' : 'Input' - const builder = new U.FormBuilder(feature, [[field, { handler }]], { + const builder = new MutatingForm(feature, [[field, { handler }]], { id: `umap-feature-properties_${L.stamp(feature)}`, }) cell.innerHTML = '' diff --git a/umap/static/umap/js/modules/umap.js b/umap/static/umap/js/modules/umap.js index 543a5a94c..aff442282 100644 --- a/umap/static/umap/js/modules/umap.js +++ b/umap/static/umap/js/modules/umap.js @@ -34,7 +34,7 @@ import { uMapAlert as Alert, } from '../components/alerts/alert.js' import Orderable from './orderable.js' -import { DataForm } from './form/builder.js' +import { MutatingForm } from './form/builder.js' export default class Umap extends ServerStored { constructor(element, geojson) { @@ -735,7 +735,7 @@ export default class Umap extends ServerStored { const metadataFields = ['properties.name', 'properties.description'] DomUtil.createTitle(container, translate('Edit map details'), 'icon-caption') - const builder = new DataForm(this, metadataFields, { + const builder = new MutatingForm(this, metadataFields, { className: 'map-metadata', umap: this, }) @@ -750,7 +750,7 @@ export default class Umap extends ServerStored { 'properties.permanentCredit', 'properties.permanentCreditBackground', ] - const creditsBuilder = new DataForm(this, creditsFields, { umap: this }) + const creditsBuilder = new MutatingForm(this, creditsFields, { umap: this }) credits.appendChild(creditsBuilder.build()) this.editPanel.open({ content: container }) } @@ -771,7 +771,7 @@ export default class Umap extends ServerStored { 'properties.captionBar', 'properties.captionMenus', ]) - const builder = new DataForm(this, UIFields, { umap: this }) + const builder = new MutatingForm(this, UIFields, { umap: this }) const controlsOptions = DomUtil.createFieldset( container, translate('User interface options') @@ -794,7 +794,7 @@ export default class Umap extends ServerStored { 'properties.dashArray', ] - const builder = new DataForm(this, shapeOptions, { umap: this }) + const builder = new MutatingForm(this, shapeOptions, { umap: this }) const defaultShapeProperties = DomUtil.createFieldset( container, translate('Default shape properties') @@ -813,7 +813,7 @@ export default class Umap extends ServerStored { 'properties.slugKey', ] - const builder = new DataForm(this, optionsFields, { umap: this }) + const builder = new MutatingForm(this, optionsFields, { umap: this }) const defaultProperties = DomUtil.createFieldset( container, translate('Default properties') @@ -831,7 +831,7 @@ export default class Umap extends ServerStored { 'properties.labelInteractive', 'properties.outlinkTarget', ] - const builder = new DataForm(this, popupFields, { umap: this }) + const builder = new MutatingForm(this, popupFields, { umap: this }) const popupFieldset = DomUtil.createFieldset( container, translate('Default interaction options') @@ -888,7 +888,7 @@ export default class Umap extends ServerStored { container, translate('Custom background') ) - const builder = new DataForm(this, tilelayerFields, { umap: this }) + const builder = new MutatingForm(this, tilelayerFields, { umap: this }) customTilelayer.appendChild(builder.build()) } @@ -936,7 +936,7 @@ export default class Umap extends ServerStored { ['properties.overlay.tms', { handler: 'Switch', label: translate('TMS format') }], ] const overlay = DomUtil.createFieldset(container, translate('Custom overlay')) - const builder = new DataForm(this, overlayFields, { umap: this }) + const builder = new MutatingForm(this, overlayFields, { umap: this }) overlay.appendChild(builder.build()) } @@ -963,7 +963,7 @@ export default class Umap extends ServerStored { { handler: 'BlurFloatInput', placeholder: translate('max East') }, ], ] - const boundsBuilder = new DataForm(this, boundsFields, { umap: this }) + const boundsBuilder = new MutatingForm(this, boundsFields, { umap: this }) limitBounds.appendChild(boundsBuilder.build()) const boundsButtons = DomUtil.create('div', 'button-bar half', limitBounds) DomUtil.createButton( @@ -1028,7 +1028,7 @@ export default class Umap extends ServerStored { { handler: 'Switch', label: translate('Autostart when map is loaded') }, ], ] - const slideshowBuilder = new DataForm(this, slideshowFields, { + const slideshowBuilder = new MutatingForm(this, slideshowFields, { callback: () => { this.slideshow.load() // FIXME when we refactor formbuilder: this callback is called in a 'postsync' @@ -1043,7 +1043,7 @@ export default class Umap extends ServerStored { _editSync(container) { const sync = DomUtil.createFieldset(container, translate('Real-time collaboration')) - const builder = new DataForm(this, ['properties.syncEnabled'], { + const builder = new MutatingForm(this, ['properties.syncEnabled'], { umap: this, }) sync.appendChild(builder.build()) @@ -1462,7 +1462,7 @@ export default class Umap extends ServerStored { const row = DomUtil.create('li', 'orderable', ul) DomUtil.createIcon(row, 'icon-drag', translate('Drag to reorder')) datalayer.renderToolbox(row) - const builder = new DataForm( + const builder = new MutatingForm( datalayer, [['options.name', { handler: 'EditableText' }]], { className: 'umap-form-inline' } diff --git a/umap/tests/integration/test_edit_map.py b/umap/tests/integration/test_edit_map.py index 6328d6997..5dc65ad93 100644 --- a/umap/tests/integration/test_edit_map.py +++ b/umap/tests/integration/test_edit_map.py @@ -60,8 +60,8 @@ def test_zoomcontrol_impacts_ui(live_server, page, tilelayer): # Hide them page.get_by_text("User interface options").click() hide_zoom_controls = ( - page.locator("div") - .filter(has_text=re.compile(r"^Display the zoom control")) + page.locator(".panel") + .filter(has_text=re.compile("Display the zoom control")) .locator("label") .nth(2) ) From 176b8bdbcc7d2c43503b3ab1a317176aac6876c6 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Tue, 7 Jan 2025 16:58:44 +0100 Subject: [PATCH 3/8] wip(forms): refactor forms templating --- umap/static/umap/css/form.css | 2 +- umap/static/umap/js/modules/data/layer.js | 2 +- umap/static/umap/js/modules/form/builder.js | 46 ++- umap/static/umap/js/modules/form/fields.js | 373 +++++++++--------- umap/static/umap/js/modules/help.js | 4 +- umap/static/umap/js/modules/utils.js | 6 +- umap/tests/integration/test_edit_polygon.py | 2 +- umap/tests/integration/test_websocket_sync.py | 8 +- 8 files changed, 242 insertions(+), 201 deletions(-) diff --git a/umap/static/umap/css/form.css b/umap/static/umap/css/form.css index 10f12f2c4..fa92e9ad6 100644 --- a/umap/static/umap/css/form.css +++ b/umap/static/umap/css/form.css @@ -1,3 +1,4 @@ +.umap-form-inline .formbox, .umap-form-inline { display: inline; } @@ -559,7 +560,6 @@ i.info { clear: both; margin-bottom: 20px; overflow: hidden; - display: none; } .umap-color-picker span { width: 20px; diff --git a/umap/static/umap/js/modules/data/layer.js b/umap/static/umap/js/modules/data/layer.js index bd36bd951..6fced9d11 100644 --- a/umap/static/umap/js/modules/data/layer.js +++ b/umap/static/umap/js/modules/data/layer.js @@ -655,7 +655,7 @@ export class DataLayer extends ServerStored { { label: translate('Data is browsable'), handler: 'Switch', - helpEntries: 'browsable', + helpEntries: ['browsable'], }, ], [ diff --git a/umap/static/umap/js/modules/form/builder.js b/umap/static/umap/js/modules/form/builder.js index cb6d0446c..5af0bfac4 100644 --- a/umap/static/umap/js/modules/form/builder.js +++ b/umap/static/umap/js/modules/form/builder.js @@ -1,6 +1,7 @@ import getClass from './fields.js' import * as Utils from '../utils.js' import { SCHEMA } from '../schema.js' +import { translate } from '../i18n.js' export class Form { constructor(obj, fields, properties) { @@ -35,9 +36,8 @@ export class Form { } buildField(field) { - field.buildLabel() + field.buildTemplate() field.build() - field.buildHelpText() } makeField(field) { @@ -115,6 +115,14 @@ export class Form { } finish() {} + + getTemplate(helper) { + return ` +
    + ${helper.getTemplate()} + +
    ` + } } export class MutatingForm extends Form { @@ -190,6 +198,40 @@ export class MutatingForm extends Form { } } + getTemplate(helper) { + let template + if (helper.properties.inheritable) { + const extraClassName = helper.get(true) === undefined ? ' undefined' : '' + template = ` +
    +
    + ${translate('clear')} + ${translate('define')} + + ${helper.getLabelTemplate()} +
    +
    + ${helper.getTemplate()} + +
    +
    ` + } else { + template = ` +
    + ${helper.getLabelTemplate()} + ${helper.getTemplate()} + +
    ` + } + return template + } + + build() { + super.build() + this._umap.help.parse(this.form) + return this.form + } + finish(helper) { helper.input?.blur() } diff --git a/umap/static/umap/js/modules/form/fields.js b/umap/static/umap/js/modules/form/fields.js index 3c183a751..fc4ac499d 100644 --- a/umap/static/umap/js/modules/form/fields.js +++ b/umap/static/umap/js/modules/form/fields.js @@ -25,53 +25,55 @@ class BaseElement { this.setProperties(properties) this.fieldEls = this.field.split('.') this.name = this.builder.getName(field) - this.parentNode = this.getParentNode() + this.id = `${this.builder.properties.id || Date.now()}.${this.name}` + } + + getDefaultProperties() { + return {} } setProperties(properties) { - this.properties = Object.assign({}, this.properties, properties) + this.properties = Object.assign( + this.getDefaultProperties(), + this.properties, + properties + ) } onDefine() {} - getParentNode() { - const classNames = ['formbox'] - if (this.properties.inheritable) { - classNames.push('inheritable') - if (this.get(true) === undefined) classNames.push('undefined') + buildTemplate() { + const template = this.builder.getTemplate(this) + const [root, elements] = Utils.loadTemplateWithRefs(template) + this.root = root + this.elements = elements + this.container = elements.container + this.form.appendChild(this.root) + } + + getTemplate() { + return '' + } + + build() { + if (this.properties.helpText) { + this.elements.helpText.textContent = this.properties.helpText + } else { + this.elements.helpText.hidden = true } - classNames.push(`umap-field-${this.name}`) - const [wrapper, { header, define, undefine, quickContainer, container }] = - Utils.loadTemplateWithRefs(` -
    - -
    -
    `) - this.wrapper = wrapper - this.wrapper.classList.add(...classNames) - this.header = header - this.form.appendChild(this.wrapper) - if (this.properties.inheritable) { - define.addEventListener('click', (event) => { + + if (this.elements.define) { + this.elements.define.addEventListener('click', (event) => { event.preventDefault() event.stopPropagation() this.fetch() this.onDefine() - this.wrapper.classList.remove('undefined') + this.root.classList.remove('undefined') }) - undefine.addEventListener('click', () => this.undefine()) - } else { - define.hidden = true - undefine.hidden = true } - - this.quickContainer = quickContainer - this.extendedContainer = container - return this.extendedContainer + if (this.elements.undefine) { + this.elements.undefine.addEventListener('click', () => this.undefine()) + } } clear() { @@ -102,44 +104,12 @@ class BaseElement { this.builder.setter(this.field, this.toJS()) } - getLabelParent() { - return this.header - } - - getHelpTextParent() { - return this.parentNode - } - - buildLabel() { - if (this.properties.label) { - const label = this.properties.label - this.label = Utils.loadTemplate(``) - const parent = this.getLabelParent() - parent.appendChild(this.label) - if (this.properties.helpEntries) { - this.builder._umap.help.button(this.label, this.properties.helpEntries) - } else if (this.properties.helpTooltip) { - const info = Utils.loadTemplate('') - this.label.appendChild(info) - info.addEventListener('mouseover', () => { - this.builder._umap.tooltip.open({ - anchor: info, - content: this.properties.helpTooltip, - position: 'top', - }) - }) - } - } - } - - buildHelpText() { - if (this.properties.helpText) { - const container = Utils.loadTemplate( - `${Utils.escapeHTML(this.properties.helpText)}` - ) - const parent = this.getHelpTextParent() - parent.appendChild(container) - } + getLabelTemplate() { + const label = this.properties.label + const help = this.properties.helpEntries?.join() || '' + return label + ? `` + : '' } fetch() {} @@ -154,35 +124,35 @@ class BaseElement { } undefine() { - this.wrapper.classList.add('undefined') + this.root.classList.add('undefined') this.clear() this.sync() } } Fields.Textarea = class extends BaseElement { + getTemplate() { + return `` + } + build() { - this.input = Utils.loadTemplate('') - if (this.properties.className) this.input.classList.add(this.properties.className) - if (this.properties.placeholder) { - this.input.placeholder = this.properties.placeholder - } - this.parentNode.appendChild(this.input) + super.build() + this.textarea = this.elements.textarea this.fetch() - this.input.addEventListener('input', () => this.sync()) - this.input.addEventListener('keypress', (event) => this.onKeyPress(event)) + this.textarea.addEventListener('input', () => this.sync()) + this.textarea.addEventListener('keypress', (event) => this.onKeyPress(event)) } fetch() { const value = this.toHTML() this.initial = value if (value) { - this.input.value = value + this.textarea.value = value } } value() { - return this.input.value + return this.textarea.value } onKeyPress(event) { @@ -195,18 +165,17 @@ Fields.Textarea = class extends BaseElement { } Fields.Input = class extends BaseElement { + getTemplate() { + return `` + } + build() { - this.input = Utils.loadTemplate('') - this.parentNode.appendChild(this.input) - this.input.type = this.type() - this.input.name = this.name + super.build() + this.input = this.elements.input this.input._helper = this if (this.properties.className) { this.input.classList.add(this.properties.className) } - if (this.properties.placeholder) { - this.input.placeholder = this.properties.placeholder - } if (this.properties.min !== undefined) { this.input.min = this.properties.min } @@ -254,11 +223,13 @@ Fields.BlurInput = class extends Fields.Input { return 'blur' } + getTemplate() { + return `${super.getTemplate()}` + } + build() { this.properties.className = 'blur' super.build() - const button = Utils.loadTemplate('') - this.input.parentNode.insertBefore(button, this.input.nextSibling) this.input.addEventListener('focus', () => this.fetch()) } @@ -305,31 +276,29 @@ const FloatMixin = (Base) => } Fields.FloatInput = class extends FloatMixin(Fields.Input) { - // options: { - // step: 'any', - // } + // TODO use public class properties when in baseline + getDefaultProperties() { + return { step: 'any' } + } } Fields.BlurFloatInput = class extends FloatMixin(Fields.BlurInput) { - // options: { - // step: 'any', - // }, + getDefaultProperties() { + return { step: 'any' } + } } Fields.CheckBox = class extends BaseElement { + getTemplate() { + return `` + } + build() { - const container = Utils.loadTemplate('
    ') - this.parentNode.appendChild(container) - this.input = Utils.loadTemplate('') - container.appendChild(this.input) - if (this.properties.className) { - this.input.classList.add(this.properties.className) - } - this.input.type = 'checkbox' - this.input.name = this.name + this.input = this.elements.input this.input._helper = this this.fetch() this.input.addEventListener('change', () => this.sync()) + super.build() } fetch() { @@ -338,7 +307,7 @@ Fields.CheckBox = class extends BaseElement { } value() { - return this.wrapper.classList.contains('undefined') ? undefined : this.input.checked + return this.root.classList.contains('undefined') ? undefined : this.input.checked } toHTML() { @@ -351,12 +320,16 @@ Fields.CheckBox = class extends BaseElement { } Fields.Select = class extends BaseElement { + getTemplate() { + return `` + } + build() { - this.select = Utils.loadTemplate(``) - this.parentNode.appendChild(this.select) + this.select = this.elements.select this.validValues = [] this.buildOptions() this.select.addEventListener('change', () => this.sync()) + super.build() } getOptions() { @@ -380,7 +353,7 @@ Fields.Select = class extends BaseElement { const option = Utils.loadTemplate('') this.select.appendChild(option) option.value = value - option.innerHTML = label + option.textContent = label if (this.toHTML() === value) { option.selected = 'selected' } @@ -444,21 +417,23 @@ Fields.NullableBoolean = class extends Fields.Select { } Fields.EditableText = class extends BaseElement { + getTemplate() { + return `` + } + + buildTemplate() { + // No wrapper at all + const template = this.getTemplate() + this.input = Utils.loadTemplate(template) + this.form.appendChild(this.input) + } + build() { - this.input = Utils.loadTemplate( - `` - ) - this.parentNode.appendChild(this.input) - this.input.contentEditable = true this.fetch() this.input.addEventListener('input', () => this.sync()) this.input.addEventListener('keypress', (event) => this.onKeyPress(event)) } - getParentNode() { - return this.form - } - value() { return this.input.textContent } @@ -480,17 +455,18 @@ Fields.ColorPicker = class extends Fields.Input { return Utils.COLORS } - getParentNode() { - super.getParentNode() - return this.quickContainer + getDefaultProperties() { + return { + placeholder: translate('Inherit'), + } + } + + getTemplate() { + return `${super.getTemplate()}` } build() { super.build() - this.input.placeholder = this.properties.placeholder || translate('Inherit') - this.container = Utils.loadTemplate('
    ') - this.extendedContainer.appendChild(this.container) - this.container.style.display = 'none' for (const color of this.getColors()) { this.addColor(color) } @@ -506,16 +482,21 @@ Fields.ColorPicker = class extends Fields.Input { } onFocus() { - this.container.style.display = 'block' + this.showPicker() this.spreadColor() } + showPicker() { + this.elements.colors.hidden = false + } + + closePicker() { + this.elements.colors.hidden = true + } + onBlur() { - const closePicker = () => { - this.container.style.display = 'none' - } // We must leave time for the click to be listened. - window.setTimeout(closePicker, 100) + window.setTimeout(() => this.closePicker(), 100) } sync() { @@ -530,12 +511,12 @@ Fields.ColorPicker = class extends Fields.Input { addColor(colorName) { const span = Utils.loadTemplate('') - this.container.appendChild(span) + this.elements.colors.appendChild(span) span.style.backgroundColor = span.title = colorName const updateColorInput = () => { this.input.value = colorName this.sync() - this.container.style.display = 'none' + this.closePicker() } span.addEventListener('mousedown', updateColorInput) } @@ -680,21 +661,25 @@ Fields.IconUrl = class extends Fields.BlurInput { return 'hidden' } - build() { - super.build() - const [container, { buttons, tabs, body, footer }] = Utils.loadTemplateWithRefs(` + getTemplate() { + return `
    -
    +
    + ${super.getTemplate()} +
    - `) - this.parentNode.appendChild(container) - this.buttons = buttons - this.tabs = tabs - this.body = body - this.footer = footer + ` + } + + build() { + super.build() + this.buttons = this.elements.buttons + this.tabs = this.elements.tabs + this.body = this.elements.body + this.footer = this.elements.footer this.updatePreview() } @@ -936,23 +921,27 @@ Fields.Url = class extends Fields.Input { } Fields.Switch = class extends Fields.CheckBox { - getParentNode() { - super.getParentNode() - if (this.properties.inheritable) return this.quickContainer - return this.extendedContainer + getTemplate() { + const label = this.properties.label + return `${super.getTemplate()}` } build() { super.build() - if (this.properties.inheritable) { - this.label = Utils.loadTemplate('') + // We have it in our template + if (!this.properties.inheritable) { + // We already have the label near the switch, + // only show the default label in inheritable mode + // as the switch itself may be hidden (until "defined") + if (this.elements.label) { + this.elements.label.hidden = true + this.elements.label.innerHTML = '' + this.elements.label.title = '' + } } - this.input.parentNode.appendChild(this.label) - this.input.parentNode.classList.add('with-switch') - const id = `${this.builder.properties.id || Date.now()}.${this.name}` - this.label.setAttribute('for', id) + this.container.classList.add('with-switch') this.input.classList.add('switch') - this.input.id = id + this.input.id = this.id } } @@ -961,22 +950,22 @@ Fields.FacetSearchBase = class extends BaseElement { } Fields.FacetSearchChoices = class extends Fields.FacetSearchBase { - build() { - const [container, { ul, label }] = Utils.loadTemplateWithRefs(` + getTemplate() { + return `
    ${Utils.escapeHTML(this.properties.label)}
      - `) - this.container = container - this.ul = ul - this.label = label - this.parentNode.appendChild(this.container) + ` + } + + 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) { @@ -993,13 +982,13 @@ Fields.FacetSearchChoices = class extends Fields.FacetSearchBase { input.checked = this.get().choices.includes(value) input.dataset.value = value input.addEventListener('change', () => this.sync()) - this.ul.appendChild(li) + this.elements.ul.appendChild(li) } toJS() { return { type: this.type, - choices: [...this.ul.querySelectorAll('input:checked')].map( + choices: [...this.elements.ul.querySelectorAll('input:checked')].map( (i) => i.dataset.value ), } @@ -1019,28 +1008,30 @@ Fields.MinMaxBase = class extends Fields.FacetSearchBase { return value.valueOf() } - build() { + getTemplate() { const [minLabel, maxLabel] = this.getLabels() 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 this.type = type const inputType = this.getInputType(this.type) const minHTML = this.prepareForHTML(min) const maxHTML = this.prepareForHTML(max) - const [container, { minInput, maxInput }] = Utils.loadTemplateWithRefs(` + return `
      ${Utils.escapeHTML(this.properties.label)}
      - `) - this.container = container - this.minInput = minInput - this.maxInput = maxInput - this.parentNode.appendChild(this.container) + ` + } + + 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 @@ -1061,6 +1052,7 @@ Fields.MinMaxBase = class extends Fields.FacetSearchBase { this.minInput.addEventListener('change', () => this.sync()) this.maxInput.addEventListener('change', () => this.sync()) + super.build() } toggleStatus() { @@ -1174,16 +1166,17 @@ Fields.MultiChoice = class extends BaseElement { return this.properties.choices || this.choices } + getTemplate() { + return `
      ` + } + build() { const choices = this.getChoices() - this.container = Utils.loadTemplate( - `
      ` - ) - this.parentNode.appendChild(this.container) for (const [i, [value, label]] of choices.entries()) { this.addChoice(value, label, i) } this.fetch() + super.build() } addChoice(value, label, counter) { @@ -1191,8 +1184,8 @@ Fields.MultiChoice = class extends BaseElement { const input = Utils.loadTemplate( `` ) - this.container.appendChild(input) - this.container.appendChild( + this.elements.wrapper.appendChild(input) + this.elements.wrapper.appendChild( Utils.loadTemplate(``) ) input.addEventListener('change', () => this.sync()) @@ -1259,10 +1252,11 @@ Fields.Range = class extends Fields.FloatInput { } value() { - return this.wrapper.classList.contains('undefined') ? undefined : super.value() + return this.root.classList.contains('undefined') ? undefined : super.value() } - buildHelpText() { + build() { + super.build() let options = '' const step = this.properties.step || 1 const digits = step < 1 ? 1 : 0 @@ -1272,16 +1266,14 @@ Fields.Range = class extends Fields.FloatInput { i <= this.properties.max; i += this.properties.step ) { - options += `` + const ii = i.toFixed(digits) + options += `` } - const parent = this.getHelpTextParent() const datalist = Utils.loadTemplate( `${options}` ) + this.container.appendChild(datalist) this.input.setAttribute('list', id) - super.buildHelpText() } } @@ -1292,12 +1284,13 @@ Fields.ManageOwner = class extends BaseElement { on_select: L.bind(this.onSelect, this), placeholder: translate("Type new owner's username"), } - this.autocomplete = new AjaxAutocomplete(this.parentNode, options) + this.autocomplete = new AjaxAutocomplete(this.container, options) const owner = this.toHTML() - if (owner) + if (owner) { this.autocomplete.displaySelected({ item: { value: owner.id, label: owner.name }, }) + } } value() { @@ -1322,7 +1315,7 @@ Fields.ManageEditors = class extends BaseElement { on_unselect: L.bind(this.onUnselect, this), placeholder: translate("Type editor's username"), } - this.autocomplete = new AjaxAutocompleteMultiple(this.parentNode, options) + this.autocomplete = new AjaxAutocompleteMultiple(this.container, options) this._values = this.toHTML() if (this._values) for (let i = 0; i < this._values.length; i++) diff --git a/umap/static/umap/js/modules/help.js b/umap/static/umap/js/modules/help.js index ddb67ad3d..84390c502 100644 --- a/umap/static/umap/js/modules/help.js +++ b/umap/static/umap/js/modules/help.js @@ -228,7 +228,9 @@ export default class Help { parse(container) { for (const element of container.querySelectorAll('[data-help]')) { - this.button(element, element.dataset.help.split(',')) + if (element.dataset.help) { + this.button(element, element.dataset.help.split(',')) + } } } diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js index b5c4664f6..69d5721a4 100644 --- a/umap/static/umap/js/modules/utils.js +++ b/umap/static/umap/js/modules/utils.js @@ -416,9 +416,11 @@ export function loadTemplate(html) { } export function loadTemplateWithRefs(html) { - const element = loadTemplate(html) + const template = document.createElement('template') + template.innerHTML = html + const element = template.content.firstElementChild const elements = {} - for (const node of element.querySelectorAll('[data-ref]')) { + for (const node of template.content.querySelectorAll('[data-ref]')) { elements[node.dataset.ref] = node } return [element, elements] diff --git a/umap/tests/integration/test_edit_polygon.py b/umap/tests/integration/test_edit_polygon.py index 5f60087bd..ec1ce7cc5 100644 --- a/umap/tests/integration/test_edit_polygon.py +++ b/umap/tests/integration/test_edit_polygon.py @@ -101,7 +101,7 @@ def test_can_remove_stroke(live_server, openmap, page, bootstrap): page.get_by_role("link", name="Toggle edit mode").click() page.get_by_text("Shape properties").click() page.locator(".umap-field-stroke .define").first.click() - page.locator(".umap-field-stroke label").first.click() + page.locator(".umap-field-stroke .show-on-defined label").first.click() expect(page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']")).to_have_count( 0 ) diff --git a/umap/tests/integration/test_websocket_sync.py b/umap/tests/integration/test_websocket_sync.py index c5e56e893..3516ddb81 100644 --- a/umap/tests/integration/test_websocket_sync.py +++ b/umap/tests/integration/test_websocket_sync.py @@ -187,9 +187,11 @@ def test_websocket_connection_can_sync_map_properties( # Zoom control is synced peerB.get_by_role("link", name="Map advanced properties").click() peerB.locator("summary").filter(has_text="User interface options").click() - peerB.locator("div").filter( - has_text=re.compile(r"^Display the zoom control") - ).locator("label").nth(2).click() + switch = peerB.locator("div.formbox").filter( + has_text=re.compile("Display the zoom control") + ) + expect(switch).to_be_visible() + switch.get_by_text("Never").click() expect(peerA.locator(".leaflet-control-zoom")).to_be_hidden() From 63e84d94c4826e8c165476cc325c61f3b0f70fac Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Wed, 8 Jan 2025 16:14:36 +0100 Subject: [PATCH 4/8] chore(forms): refactor icon preview of IconURL field --- umap/static/umap/css/form.css | 29 ++++++++----------- umap/static/umap/js/modules/form/builder.js | 8 +++-- umap/static/umap/js/modules/form/fields.js | 24 +++++++-------- umap/tests/integration/test_edit_datalayer.py | 2 +- umap/tests/integration/test_picto.py | 16 +++++----- 5 files changed, 37 insertions(+), 42 deletions(-) diff --git a/umap/static/umap/css/form.css b/umap/static/umap/css/form.css index fa92e9ad6..e4ad44272 100644 --- a/umap/static/umap/css/form.css +++ b/umap/static/umap/css/form.css @@ -382,16 +382,19 @@ input.switch:checked ~ label:after { box-shadow: inset 0 0 6px 0px #2c3233; color: var(--color-darkGray); } -.inheritable .header, -.inheritable { - clear: both; - overflow: hidden; +.inheritable .header .buttons { + padding: 0; } .inheritable .header { margin-bottom: 5px; + display: flex; + align-items: center; + align-content: center; + justify-content: space-between; } .inheritable .header label { padding-top: 6px; + width: initial; } .inheritable + .inheritable { border-top: 1px solid #222; @@ -401,22 +404,11 @@ input.switch:checked ~ label:after { .umap-field-iconUrl .action-button, .inheritable .define, .inheritable .undefine { - float: inline-end; width: initial; min-height: 18px; line-height: 18px; margin-bottom: 0; } -.inheritable .quick-actions { - float: inline-end; -} -.inheritable .quick-actions .formbox { - margin-bottom: 0; -} -.inheritable .quick-actions input { - width: 100px; - margin-inline-end: 5px; -} .inheritable .define, .inheritable.undefined .undefine, .inheritable.undefined .show-on-defined { @@ -494,12 +486,15 @@ i.info { padding: 0 5px; } .flat-tabs { - display: flex; + display: none; justify-content: space-around; font-size: 1.2em; margin-bottom: 20px; border-bottom: 1px solid #bebebe; } +.flat-tabs:has(.flat) { + display: flex; +} .flat-tabs button { padding: 10px; text-decoration: none; @@ -535,7 +530,7 @@ i.info { background-color: #999; text-align: center; margin-bottom: 5px; - display: block; + display: inline-block; color: black; font-weight: bold; } diff --git a/umap/static/umap/js/modules/form/builder.js b/umap/static/umap/js/modules/form/builder.js index 5af0bfac4..e16b9b7a3 100644 --- a/umap/static/umap/js/modules/form/builder.js +++ b/umap/static/umap/js/modules/form/builder.js @@ -205,10 +205,12 @@ export class MutatingForm extends Form { template = `
      - ${translate('clear')} - ${translate('define')} - ${helper.getLabelTemplate()} + + + + +
      ${helper.getTemplate()} diff --git a/umap/static/umap/js/modules/form/fields.js b/umap/static/umap/js/modules/form/fields.js index fc4ac499d..29fc03b7b 100644 --- a/umap/static/umap/js/modules/form/fields.js +++ b/umap/static/umap/js/modules/form/fields.js @@ -664,7 +664,6 @@ Fields.IconUrl = class extends Fields.BlurInput { getTemplate() { return `
      -
      ${super.getTemplate()} @@ -676,15 +675,18 @@ Fields.IconUrl = class extends Fields.BlurInput { build() { super.build() - this.buttons = this.elements.buttons this.tabs = this.elements.tabs this.body = this.elements.body this.footer = this.elements.footer + this.button = Utils.loadTemplate( + `` + ) + this.button.addEventListener('click', () => this.onDefine()) + this.elements.buttons.appendChild(this.button) this.updatePreview() } async onDefine() { - this.buttons.innerHTML = '' this.footer.innerHTML = '' const [{ pictogram_list }, response, error] = await this.builder._umap.server.get( this.builder._umap.properties.urls.pictogram_list_json @@ -692,14 +694,14 @@ Fields.IconUrl = class extends Fields.BlurInput { if (!error) this.pictogram_list = pictogram_list this.buildTabs() const value = this.value() - if (U.Icon.RECENT.length) this.showRecentTab() + if (Icon.RECENT.length) this.showRecentTab() else if (!value || Utils.isPath(value)) this.showSymbolsTab() else if (Utils.isRemoteUrl(value) || Utils.isDataImage(value)) this.showURLTab() else this.showCharsTab() const closeButton = Utils.loadTemplate( `` ) - closeButton.addEventListener('click', () => { + closeButton.addEventListener('click', (event) => { this.body.innerHTML = '' this.tabs.innerHTML = '' this.footer.innerHTML = '' @@ -758,21 +760,16 @@ Fields.IconUrl = class extends Fields.BlurInput { } updatePreview() { - this.buttons.innerHTML = '' + this.elements.actions.innerHTML = '' + this.button.hidden = !this.value() || this.isDefault() if (this.isDefault()) return if (!Utils.hasVar(this.value())) { // Do not try to render URL with variables const box = Utils.loadTemplate('
      ') - this.buttons.appendChild(box) + this.elements.actions.appendChild(box) box.addEventListener('click', () => this.onDefine()) const icon = Icon.makeElement(this.value(), box) } - const text = this.value() ? translate('Change') : translate('Add') - const button = Utils.loadTemplate( - `` - ) - button.addEventListener('click', () => this.onDefine()) - this.buttons.appendChild(button) } addIconPreview(pictogram, parent) { @@ -794,6 +791,7 @@ Fields.IconUrl = class extends Fields.BlurInput { this.sync() this.unselectAll(this.grid) container.classList.add('selected') + this.updatePreview() }) return true // Icon has been added (not filtered) } diff --git a/umap/tests/integration/test_edit_datalayer.py b/umap/tests/integration/test_edit_datalayer.py index f9a9fa076..a14c87640 100644 --- a/umap/tests/integration/test_edit_datalayer.py +++ b/umap/tests/integration/test_edit_datalayer.py @@ -103,7 +103,7 @@ def test_can_change_icon_class(live_server, openmap, page): expect(page.locator(".umap-circle-icon")).to_be_hidden() page.locator(".panel.right").get_by_title("Edit", exact=True).click() page.get_by_text("Shape properties").click() - page.locator(".umap-field-iconClass a.define").click() + page.locator(".umap-field-iconClass button.define").click() page.get_by_text("Circle", exact=True).click() expect(page.locator(".umap-circle-icon")).to_be_visible() expect(page.locator(".umap-div-icon")).to_be_hidden() diff --git a/umap/tests/integration/test_picto.py b/umap/tests/integration/test_picto.py index d4b38954c..f561a4595 100644 --- a/umap/tests/integration/test_picto.py +++ b/umap/tests/integration/test_picto.py @@ -57,7 +57,7 @@ def test_can_change_picto_at_map_level(openmap, live_server, page, pictos): define.click() # No picto defined yet, so recent should not be visible expect(page.get_by_text("Recent")).to_be_hidden() - symbols = page.locator(".umap-pictogram-choice") + symbols = page.locator(".umap-pictogram-body .umap-pictogram-choice") expect(symbols).to_have_count(2) search = page.locator(".umap-pictogram-body input") search.type("star") @@ -90,8 +90,8 @@ def test_can_change_picto_at_datalayer_level(openmap, live_server, page, pictos) expect(define).to_be_visible() expect(undefine).to_be_hidden() define.click() - # Map has an icon defined, so it shold open on Recent tab - symbols = page.locator(".umap-pictogram-choice") + # Map has an icon defined, so it should open on Recent tab + symbols = page.locator(".umap-pictogram-body .umap-pictogram-choice") expect(page.get_by_text("Recent")).to_be_visible() expect(symbols).to_have_count(1) symbol_tab = page.get_by_role("button", name="Symbol") @@ -128,8 +128,8 @@ def test_can_change_picto_at_marker_level(openmap, live_server, page, pictos): expect(define).to_be_visible() expect(undefine).to_be_hidden() define.click() - # Map has an icon defined, so it shold open on Recent tab - symbols = page.locator(".umap-pictogram-choice") + # Map has an icon defined, so it shuold open on Recent tab + symbols = page.locator(".umap-pictogram-body .umap-pictogram-choice") expect(page.get_by_text("Recent")).to_be_visible() expect(symbols).to_have_count(1) symbol_tab = page.get_by_role("button", name="Symbol") @@ -180,7 +180,7 @@ def test_can_use_remote_url_as_picto(openmap, live_server, page, pictos): expect(modify).to_be_visible() modify.click() # Should be on Recent tab - symbols = page.locator(".umap-pictogram-choice") + symbols = page.locator(".umap-pictogram-body .umap-pictogram-choice") expect(page.get_by_text("Recent")).to_be_visible() expect(symbols).to_have_count(1) @@ -215,10 +215,10 @@ def test_can_use_char_as_picto(openmap, live_server, page, pictos): close.click() edit_settings.click() shape_settings.click() - preview = page.locator(".umap-pictogram-choice") + preview = page.locator(".header .umap-pictogram-choice") expect(preview).to_be_visible() preview.click() # Should be on URL tab - symbols = page.locator(".umap-pictogram-choice") + symbols = page.locator(".umap-pictogram-body .umap-pictogram-choice") expect(page.get_by_text("Recent")).to_be_visible() expect(symbols).to_have_count(1) From b6c8d64c476825bd1c3a803f51e36b5dee94ffee Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Wed, 8 Jan 2025 16:15:27 +0100 Subject: [PATCH 5/8] chore(forms): remove duplicate NullableBoolean field --- umap/static/umap/js/modules/form/fields.js | 27 ---------------------- 1 file changed, 27 deletions(-) diff --git a/umap/static/umap/js/modules/form/fields.js b/umap/static/umap/js/modules/form/fields.js index 29fc03b7b..ea0740a98 100644 --- a/umap/static/umap/js/modules/form/fields.js +++ b/umap/static/umap/js/modules/form/fields.js @@ -389,33 +389,6 @@ Fields.IntSelect = class extends Fields.Select { } } -Fields.NullableBoolean = class extends Fields.Select { - getOptions() { - return [ - [undefined, 'inherit'], - [true, 'yes'], - [false, 'no'], - ] - } - - toJS() { - let value = this.value() - switch (value) { - case 'true': - case true: - value = true - break - case 'false': - case false: - value = false - break - default: - value = undefined - } - return value - } -} - Fields.EditableText = class extends BaseElement { getTemplate() { return `` From fb4fecd337538b32b4f5930c0b4a646f293c48ba Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 9 Jan 2025 13:00:59 +0100 Subject: [PATCH 6/8] chore(tests): fix sync tests --- umap/tests/integration/test_websocket_sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/umap/tests/integration/test_websocket_sync.py b/umap/tests/integration/test_websocket_sync.py index 3516ddb81..53c063d6c 100644 --- a/umap/tests/integration/test_websocket_sync.py +++ b/umap/tests/integration/test_websocket_sync.py @@ -280,7 +280,7 @@ def test_websocket_connection_can_sync_cloned_polygons( peerB.locator("path").nth(1).drag_to(b_map_el, target_position={"x": 400, "y": 400}) peerB.locator("path").nth(1).click() peerB.locator("summary").filter(has_text="Shape properties").click() - peerB.locator(".header > a:nth-child(2)").first.click() + peerB.locator(".umap-field-color button.define").first.click() peerB.get_by_title("Orchid", exact=True).first.click() peerB.locator("#map").press("Escape") peerB.get_by_role("button", name="Save").click() From 0ba69e41d08b07a1e39f1a238965626b28b69be0 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 9 Jan 2025 13:02:04 +0100 Subject: [PATCH 7/8] wip(forms): use events instead of callback --- umap/static/umap/js/modules/browser.js | 10 +++--- umap/static/umap/js/modules/data/features.js | 33 +++++++++----------- umap/static/umap/js/modules/data/layer.js | 14 ++++----- umap/static/umap/js/modules/form/builder.js | 3 +- umap/static/umap/js/modules/form/fields.js | 9 +----- umap/static/umap/js/modules/share.js | 5 ++- umap/static/umap/js/modules/umap.js | 11 +++---- umap/static/umap/js/modules/utils.js | 23 ++++++++++++++ 8 files changed, 56 insertions(+), 52 deletions(-) diff --git a/umap/static/umap/js/modules/browser.js b/umap/static/umap/js/modules/browser.js index 01b20ebb8..4c2f9d602 100644 --- a/umap/static/umap/js/modules/browser.js +++ b/umap/static/umap/js/modules/browser.js @@ -180,9 +180,8 @@ export default class Browser { ], ['options.inBbox', { handler: 'Switch', label: translate('Current map view') }], ] - const builder = new Form(this, fields, { - callback: () => this.onFormChange(), - }) + const builder = new Form(this, fields) + builder.on('set', () => this.onFormChange()) let filtersBuilder this.formContainer.appendChild(builder.build()) DomEvent.on(builder.form, 'reset', () => { @@ -190,9 +189,8 @@ export default class Browser { }) if (this._umap.properties.facetKey) { fields = this._umap.facets.build() - filtersBuilder = new Form(this._umap.facets, fields, { - callback: () => this.onFormChange(), - }) + filtersBuilder = new Form(this._umap.facets, fields) + filtersBuilder.on('set', () => this.onFormChange()) DomEvent.on(filtersBuilder.form, 'reset', () => { window.setTimeout(filtersBuilder.syncAll.bind(filtersBuilder)) }) diff --git a/umap/static/umap/js/modules/data/features.js b/umap/static/umap/js/modules/data/features.js index 707398041..18ce98f95 100644 --- a/umap/static/umap/js/modules/data/features.js +++ b/umap/static/umap/js/modules/data/features.js @@ -226,15 +226,11 @@ class Feature { `icon-${this.getClassName()}` ) - let builder = new MutatingForm( - this, - [['datalayer', { handler: 'DataLayerSwitcher' }]], - { - callback() { - this.edit(event) - }, // removeLayer step will close the edit panel, let's reopen it - } - ) + let builder = new MutatingForm(this, [ + ['datalayer', { handler: 'DataLayerSwitcher' }], + ]) + // removeLayer step will close the edit panel, let's reopen it + builder.on('set', () => this.edit(event)) container.appendChild(builder.build()) const properties = [] @@ -734,16 +730,15 @@ export class Point extends Feature { ['ui._latlng.lat', { handler: 'FloatInput', label: translate('Latitude') }], ['ui._latlng.lng', { handler: 'FloatInput', label: translate('Longitude') }], ] - const builder = new MutatingForm(this, coordinatesOptions, { - callback: () => { - if (!this.ui._latlng.isValid()) { - Alert.error(translate('Invalid latitude or longitude')) - builder.restoreField('ui._latlng.lat') - builder.restoreField('ui._latlng.lng') - } - this.pullGeometry() - this.zoomTo({ easing: false }) - }, + const builder = new MutatingForm(this, coordinatesOptions) + builder.on('set', () => { + if (!this.ui._latlng.isValid()) { + Alert.error(translate('Invalid latitude or longitude')) + builder.restoreField('ui._latlng.lat') + builder.restoreField('ui._latlng.lng') + } + this.pullGeometry() + this.zoomTo({ easing: false }) }) const fieldset = DomUtil.createFieldset(container, translate('Coordinates')) fieldset.appendChild(builder.build()) diff --git a/umap/static/umap/js/modules/data/layer.js b/umap/static/umap/js/modules/data/layer.js index 6fced9d11..e35e51b16 100644 --- a/umap/static/umap/js/modules/data/layer.js +++ b/umap/static/umap/js/modules/data/layer.js @@ -667,14 +667,12 @@ export class DataLayer extends ServerStored { ], ] DomUtil.createTitle(container, translate('Layer properties'), 'icon-layers') - let builder = new MutatingForm(this, metadataFields, { - callback: (helper) => { - console.log(helper) - this._umap.onDataLayersChanged() - if (helper.field === 'options.type') { - this.edit() - } - }, + let builder = new MutatingForm(this, metadataFields) + builder.on('set', (helper) => { + this._umap.onDataLayersChanged() + if (helper.field === 'options.type') { + this.edit() + } }) container.appendChild(builder.build()) diff --git a/umap/static/umap/js/modules/form/builder.js b/umap/static/umap/js/modules/form/builder.js index e16b9b7a3..ea78a10f6 100644 --- a/umap/static/umap/js/modules/form/builder.js +++ b/umap/static/umap/js/modules/form/builder.js @@ -3,8 +3,9 @@ import * as Utils from '../utils.js' import { SCHEMA } from '../schema.js' import { translate } from '../i18n.js' -export class Form { +export class Form extends Utils.WithEvents { constructor(obj, fields, properties) { + super() this.setProperties(properties) this.defaultProperties = {} this.obj = obj diff --git a/umap/static/umap/js/modules/form/fields.js b/umap/static/umap/js/modules/form/fields.js index ea0740a98..a74de302e 100644 --- a/umap/static/umap/js/modules/form/fields.js +++ b/umap/static/umap/js/modules/form/fields.js @@ -97,7 +97,7 @@ class BaseElement { sync() { this.set() - this.onPostSync() + this.builder.fire('set', this) } set() { @@ -116,13 +116,6 @@ class BaseElement { finish() {} - onPostSync() { - if (this.properties.callback) { - this.properties.callback(this) - } - this.builder.onPostSync(this) - } - undefine() { this.root.classList.add('undefined') this.clear() diff --git a/umap/static/umap/js/modules/share.js b/umap/static/umap/js/modules/share.js index 6a0a49cee..ccf936366 100644 --- a/umap/static/umap/js/modules/share.js +++ b/umap/static/umap/js/modules/share.js @@ -126,9 +126,8 @@ export default class Share { exportUrl.value = window.location.protocol + iframeExporter.buildUrl() } buildIframeCode() - const builder = new MutatingForm(iframeExporter, UIFields, { - callback: buildIframeCode, - }) + const builder = new MutatingForm(iframeExporter, UIFields) + builder.on('set', buildIframeCode) const iframeOptions = DomUtil.createFieldset( this.container, translate('Embed and link options') diff --git a/umap/static/umap/js/modules/umap.js b/umap/static/umap/js/modules/umap.js index aff442282..ab4d94fe6 100644 --- a/umap/static/umap/js/modules/umap.js +++ b/umap/static/umap/js/modules/umap.js @@ -1029,13 +1029,6 @@ export default class Umap extends ServerStored { ], ] const slideshowBuilder = new MutatingForm(this, slideshowFields, { - callback: () => { - this.slideshow.load() - // FIXME when we refactor formbuilder: this callback is called in a 'postsync' - // event, which comes after the call of `setter` method, which will call the - // map.render method, which should do this redraw. - this.bottomBar.redraw() - }, umap: this, }) slideshow.appendChild(slideshowBuilder.build()) @@ -1351,6 +1344,10 @@ export default class Umap extends ServerStored { } this.topBar.redraw() }, + 'properties.slideshow.active': () => { + this.slideshow.load() + this.bottomBar.redraw() + }, numberOfConnectedPeers: () => { Utils.eachElement('.connected-peers span', (el) => { if (this.sync.websocketConnected) { diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js index 69d5721a4..b705f2c09 100644 --- a/umap/static/umap/js/modules/utils.js +++ b/umap/static/umap/js/modules/utils.js @@ -449,6 +449,29 @@ export function eachElement(selector, callback) { } } +export class WithEvents { + constructor() { + this._callbacks = {} + } + + on(eventType, callback) { + if (typeof callback !== 'function') return + if (this._callbacks[eventType] === undefined) { + this._callbacks[eventType] = [] + } + + this._callbacks[eventType].push(callback) + } + + fire(eventType, ...args) { + if (this._callbacks[eventType] === undefined) return + + for (const callback of this._callbacks[eventType]) { + callback(...args) + } + } +} + export const COLORS = [ 'Black', 'Navy', From 07c29abbec08707e814f5170d18caa9752f5ed6e Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 10 Jan 2025 16:18:10 +0100 Subject: [PATCH 8/8] chore(utils): use native events instead of array of callbacks for WithEvents Co-authored-by: David Larlet --- umap/static/umap/js/modules/data/layer.js | 4 ++-- umap/static/umap/js/modules/form/fields.js | 2 +- umap/static/umap/js/modules/utils.js | 17 +++++------------ 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/umap/static/umap/js/modules/data/layer.js b/umap/static/umap/js/modules/data/layer.js index e35e51b16..c53d91632 100644 --- a/umap/static/umap/js/modules/data/layer.js +++ b/umap/static/umap/js/modules/data/layer.js @@ -668,9 +668,9 @@ export class DataLayer extends ServerStored { ] DomUtil.createTitle(container, translate('Layer properties'), 'icon-layers') let builder = new MutatingForm(this, metadataFields) - builder.on('set', (helper) => { + builder.on('set', ({ detail }) => { this._umap.onDataLayersChanged() - if (helper.field === 'options.type') { + if (detail.helper.field === 'options.type') { this.edit() } }) diff --git a/umap/static/umap/js/modules/form/fields.js b/umap/static/umap/js/modules/form/fields.js index a74de302e..1ff8b5b0a 100644 --- a/umap/static/umap/js/modules/form/fields.js +++ b/umap/static/umap/js/modules/form/fields.js @@ -97,7 +97,7 @@ class BaseElement { sync() { this.set() - this.builder.fire('set', this) + this.builder.fire('set', { helper: this }) } set() { diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js index b705f2c09..b36bc8402 100644 --- a/umap/static/umap/js/modules/utils.js +++ b/umap/static/umap/js/modules/utils.js @@ -451,24 +451,17 @@ export function eachElement(selector, callback) { export class WithEvents { constructor() { - this._callbacks = {} + this._target = new EventTarget() } on(eventType, callback) { if (typeof callback !== 'function') return - if (this._callbacks[eventType] === undefined) { - this._callbacks[eventType] = [] - } - - this._callbacks[eventType].push(callback) + this._target.addEventListener(eventType, callback) } - fire(eventType, ...args) { - if (this._callbacks[eventType] === undefined) return - - for (const callback of this._callbacks[eventType]) { - callback(...args) - } + fire(eventType, detail) { + const event = new CustomEvent(eventType, { detail }) + this._target.dispatchEvent(event) } }