diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css index 2a12c8779..f13cf1105 100644 --- a/umap/static/umap/base.css +++ b/umap/static/umap/base.css @@ -194,6 +194,7 @@ input[type="submit"] { .dark a { color: #eeeeec; } +button.flat, [type="button"].flat, .dark [type="button"].flat { border: none; @@ -536,9 +537,30 @@ i.info { margin-top: -8px; padding: 0 5px; } -.umap-pictogram-grid { +.pictogram-tabs { display: flex; - flex-wrap: wrap; + justify-content: space-around; + font-size: 1.2em; + padding-bottom: 20px; +} +.pictogram-tabs button { + padding: 10px; + color: #fff; + text-decoration: none; + cursor: pointer; +} +.pictogram-tabs .on { + font-weight: bold; + border-bottom: 1px solid #fff; +} +.umap-pictogram-category h6 { + font-size: 1.3em; +} +.umap-pictogram-grid { + display: grid; + grid-template-columns: repeat(auto-fill, 30px); + justify-content: space-between; + grid-gap: 5px; } .umap-pictogram-choice { width: 30px; @@ -548,17 +570,20 @@ i.info { background-color: #999; text-align: center; margin-bottom: 5px; - margin-right: 5px; + display: block; } .umap-pictogram-choice img { vertical-align: middle; max-width: 24px; } .umap-pictogram-choice:hover, -.umap-pictogram-choice.selected, .umap-color-picker span:hover { - box-shadow: 0 0 4px 0 black; + background-color: #bebebe; } +.umap-pictogram-choice.selected { + box-shadow: inset 0 0 0 1px #e9e9e9; +} + .umap-pictogram-choice .leaflet-marker-icon { bottom: 0; left: 30px; diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index f392322ac..391ec2e64 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -287,6 +287,13 @@ L.Util.copyToClipboard = function (textToCopy) { } } +L.Util.normalize = function (s) { + return (s || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') +} + L.DomUtil.add = (tagName, className, container, content) => { const el = L.DomUtil.create(tagName, className, container) if (content) { diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 20cd99b3e..2aa84e768 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -1,4 +1,10 @@ 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( @@ -29,15 +35,10 @@ L.FormBuilder.Element.include({ }, this ) - L.DomEvent.on( + L.DomEvent.on(undefine, 'click', L.DomEvent.stop).on( undefine, 'click', - function (e) { - L.DomEvent.stop(e) - L.DomUtil.addClass(this.wrapper, 'undefined') - this.clear() - this.sync() - }, + this.undefine, this ) } @@ -524,48 +525,111 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({ build: function () { L.FormBuilder.BlurInput.prototype.build.call(this) - // Try to guess if the icon content has been customized, and if yes - // directly display the field - this.input.type = this.value() && !this.value().startsWith('/') ? 'text' : 'hidden' - this.input.placeholder = L._('Symbol or url') - this.buttonsContainer = L.DomUtil.create('div', '') - this.pictogramsContainer = L.DomUtil.create('div', 'umap-pictogram-list') - L.DomUtil.before(this.input, this.buttonsContainer) - L.DomUtil.before(this.input, this.pictogramsContainer) + this.buttons = L.DomUtil.create('div', '', this.parentNode) + this.tabs = L.DomUtil.create('div', 'pictogram-tabs', this.parentNode) + this.body = L.DomUtil.create('div', 'umap-pictogram-body', this.parentNode) + this.footer = L.DomUtil.create('div', '', this.parentNode) this.udpatePreview() - this.on('define', this.fetchIconList) + this.on('define', this.onDefine) + }, + + onDefine: function () { + this.buttons.innerHTML = '' + this.footer.innerHTML = '' + this.buildTabs() + const value = this.value() + if (!value || value.startsWith('/')) this.showSymbolsTab() + else if (value.startsWith('http')) 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.udpatePreview() + }, + this + ) + }, + + buildTabs: function () { + this.tabs.innerHTML = '' + const symbol = L.DomUtil.add( + 'button', + 'flat tab-symbols', + this.tabs, + L._('Symbol') + ), + 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 (let el of els) { + L.DomUtil.removeClass(el, 'on') + } + let el = this.tabs.querySelector(`.tab-${name}`) + L.DomUtil.addClass(el, 'on') + this.body.innerHTML = '' + }, + + isPath: function () { + const value = this.value() + return value && value.length && value.startsWith('/') + }, + + isRemoteUrl: function () { + const value = this.value() + return value && value.length && value.startsWith('http') }, - isUrl: function () { - return this.value() && this.value().indexOf('/') !== -1 + isImg: function () { + return this.isPath() || this.isRemoteUrl() }, udpatePreview: function () { + this.buttons.innerHTML = '' + if (this.isDefault()) return if (!L.Util.hasVar(this.value())) { // Do not try to render URL with variables - if (this.isUrl()) { - const img = L.DomUtil.create( - 'img', - '', - L.DomUtil.create('div', 'umap-pictogram-choice', this.buttonsContainer) - ) + const box = L.DomUtil.create('div', 'umap-pictogram-choice', this.buttons) + L.DomEvent.on(box, 'click', this.onDefine, this) + if (this.isImg()) { + const img = L.DomUtil.create('img', '', box) img.src = this.value() - L.DomEvent.on(img, 'click', this.fetchIconList, this) } else { - const el = L.DomUtil.create( - 'span', - '', - L.DomUtil.create('div', 'umap-pictogram-choice', this.buttonsContainer) - ) + const el = L.DomUtil.create('span', '', box) el.textContent = this.value() - L.DomEvent.on(el, 'click', this.fetchIconList, this) } } this.button = L.DomUtil.createButton( 'button action-button', - this.buttonsContainer, + this.buttons, this.value() ? L._('Change') : L._('Add'), - this.fetchIconList, + this.onDefine, this ) }, @@ -573,64 +637,54 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({ addIconPreview: function (pictogram, parent) { const baseClass = 'umap-pictogram-choice', value = pictogram.src, - className = value === this.value() ? `${baseClass} selected` : baseClass, + search = L.Util.normalize(this.searchInput.value), + title = pictogram.attribution + ? `${pictogram.name} — © ${pictogram.attribution}` + : pictogram.name + if (search && L.Util.normalize(title).indexOf(search) === -1) return + const className = value === this.value() ? `${baseClass} selected` : baseClass, container = L.DomUtil.create('div', className, parent), img = L.DomUtil.create('img', '', container) img.src = value - if (pictogram.name && pictogram.attribution) { - container.title = `${pictogram.name} — © ${pictogram.attribution}` - } + container.title = title L.DomEvent.on( container, 'click', function (e) { this.input.value = value this.sync() - this.unselectAll(this.pictogramsContainer) + 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.pictogramsContainer) + this.unselectAll(this.body) this.sync() - this.pictogramsContainer.innerHTML = '' + this.body.innerHTML = '' this.udpatePreview() }, - search: function (e) { - const icons = [...this.parentNode.querySelectorAll('.umap-pictogram-choice')], - search = this.searchInput.value.toLowerCase() - icons.forEach((el) => { - if (el.title.toLowerCase().indexOf(search) != -1) el.style.display = 'block' - else el.style.display = 'none' - }) - }, - addCategory: function (category, items) { - const parent = L.DomUtil.create( - 'div', - 'umap-pictogram-category', - this.pictogramsContainer - ), + const parent = L.DomUtil.create('div', 'umap-pictogram-category'), title = L.DomUtil.add('h6', '', parent, category), grid = L.DomUtil.create('div', 'umap-pictogram-grid', parent) + let status = false for (let item of items) { - this.addIconPreview(item, grid) + status = this.addIconPreview(item, grid) || status } + if (status) this.grid.appendChild(parent) }, - buildIconList: function (data) { - this.searchInput = L.DomUtil.create('input', '', this.pictogramsContainer) - this.searchInput.type = 'search' - this.searchInput.placeholder = L._('Search') - L.DomEvent.on(this.searchInput, 'input', this.search, this) + buildSymbolsList: function () { + this.grid.innerHTML = '' const categories = {} let category - for (const props of data.pictogram_list) { + for (const props of this.pictogram_list) { category = props.category || L._('Generic') categories[category] = categories[category] || [] categories[category].push(props) @@ -641,39 +695,60 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({ for (let [category, items] of sorted) { this.addCategory(category, items) } - const closeButton = L.DomUtil.createButton( - 'button action-button', - this.pictogramsContainer, - L._('Close'), - function (e) { - this.pictogramsContainer.innerHTML = '' - this.udpatePreview() - }, - this - ) - closeButton.style.display = 'block' - closeButton.style.clear = 'both' + }, - const customButton = L.DomUtil.createButton( - 'flat', - this.pictogramsContainer, - L._('Toggle direct input (advanced)'), - function (e) { - this.input.type = this.input.type === 'text' ? 'hidden' : 'text' - }, - this - ) - this.builder.map.help.button(customButton, 'formatIconSymbol') + isDefault: function () { + return !this.value() || this.value() === this.obj.getMap().options.default_iconUrl + }, + + showSymbolsTab: function () { + this.openTab('symbols') + 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', this.buildSymbolsList, this) + if (this.pictogram_list) { + this.buildSymbolsList() + } else { + this.builder.map.get(this.builder.map.options.urls.pictogram_list_json, { + callback: (data) => { + this.pictogram_list = data.pictogram_list + this.buildSymbolsList() + }, + context: this, + }) + } + }, + + showCharsTab: function () { + this.openTab('chars') + const value = !this.isImg() ? 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 = this.isRemoteUrl() ? this.value() : null + const input = this.buildInput(this.body, value) + input.placeholder = L._('Add image URL') + input.type = 'url' }, - fetchIconList: function (e) { - // Clean parent element before calling ajax, to prevent blinking - this.pictogramsContainer.innerHTML = '' - this.buttonsContainer.innerHTML = '' - this.builder.map.get(this.builder.map.options.urls.pictogram_list_json, { - callback: this.buildIconList, - context: this, + 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: function (container) { diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css index 69e59440f..434faae4c 100644 --- a/umap/static/umap/map.css +++ b/umap/static/umap/map.css @@ -1298,6 +1298,7 @@ a.add-datalayer:hover, vertical-align: middle; color: white; font-weight: bold; + font-size: 1.2rem; } .umap-circle-icon { border: 1px solid white; diff --git a/umap/static/umap/test/Util.js b/umap/static/umap/test/Util.js index 07d2b2847..3449fc40d 100644 --- a/umap/static/umap/test/Util.js +++ b/umap/static/umap/test/Util.js @@ -475,6 +475,16 @@ describe('L.Util', function () { }) }) + describe("#normalize()", function () { + + if('should remove accents', function () { + // French é + assert.equal(L.Util.normalize('aéroport'), 'aeroport') + // American é + assert.equal(L.Util.normalize('aéroport'), 'aeroport') + }) + }) + describe("#sortFeatures()", function () { let feat1, feat2, feat3 before(function () { diff --git a/umap/tests/conftest.py b/umap/tests/conftest.py index 5cbe70318..249f58552 100644 --- a/umap/tests/conftest.py +++ b/umap/tests/conftest.py @@ -24,11 +24,8 @@ def pytest_configure(config): settings.MEDIA_ROOT = TMP_ROOT -def pytest_unconfigure(config): - shutil.rmtree(TMP_ROOT, ignore_errors=True) - - def pytest_runtest_teardown(): + shutil.rmtree(TMP_ROOT, ignore_errors=True) cache.clear() diff --git a/umap/tests/fixtures/circle.svg b/umap/tests/fixtures/circle.svg new file mode 100644 index 000000000..9b579a760 --- /dev/null +++ b/umap/tests/fixtures/circle.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/umap/tests/fixtures/star.svg b/umap/tests/fixtures/star.svg new file mode 100644 index 000000000..2b6f51694 --- /dev/null +++ b/umap/tests/fixtures/star.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/umap/tests/integration/test_picto.py b/umap/tests/integration/test_picto.py new file mode 100644 index 000000000..5a960f204 --- /dev/null +++ b/umap/tests/integration/test_picto.py @@ -0,0 +1,217 @@ +from pathlib import Path + +import pytest +from playwright.sync_api import expect +from django.core.files.base import ContentFile + +from umap.models import Map, Pictogram + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + + +DATALAYER_DATA = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [13.68896484375, 48.55297816440071], + }, + "properties": {"_umap_options": {"color": "DarkCyan"}, "name": "Here"}, + } + ], + "_umap_options": {"displayOnLoad": True, "name": "FooBarFoo"}, +} +FIXTURES = Path(__file__).parent.parent / "fixtures" + + +@pytest.fixture +def pictos(): + path = FIXTURES / "star.svg" + Pictogram(name="star", pictogram=ContentFile(path.read_text(), path.name)).save() + path = FIXTURES / "circle.svg" + Pictogram(name="circle", pictogram=ContentFile(path.read_text(), path.name)).save() + + +def test_can_change_picto_at_map_level(map, live_server, page, pictos): + # Faster than doing a login + map.edit_status = Map.ANONYMOUS + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA) + page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + marker = page.locator(".umap-div-icon img") + expect(marker).to_have_count(1) + # Should have default img + expect(marker).to_have_attribute("src", "/static/umap/img/marker.png") + edit_settings = page.get_by_title("Edit map settings") + expect(edit_settings).to_be_visible() + edit_settings.click() + shape_settings = page.get_by_text("Default shape properties") + expect(shape_settings).to_be_visible() + shape_settings.click() + define = page.locator(".umap-field-iconUrl .define") + undefine = page.locator(".umap-field-iconUrl .undefine") + expect(define).to_be_visible() + expect(undefine).to_be_hidden() + define.click() + symbols = page.locator(".umap-pictogram-choice") + expect(symbols).to_have_count(2) + search = page.locator(".umap-pictogram-body input") + search.type("star") + expect(symbols).to_have_count(1) + symbols.click() + expect(marker).to_have_attribute("src", "/uploads/pictogram/star.svg") + undefine.click() + expect(marker).to_have_attribute("src", "/static/umap/img/marker.png") + + +def test_can_change_picto_at_datalayer_level(map, live_server, page, pictos): + # Faster than doing a login + map.edit_status = Map.ANONYMOUS + map.settings["properties"]["iconUrl"] = "/uploads/pictogram/star.svg" + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA) + page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + marker = page.locator(".umap-div-icon img") + expect(marker).to_have_count(1) + # Should have default img + expect(marker).to_have_attribute("src", "/uploads/pictogram/star.svg") + # Edit datalayer + marker.click(modifiers=["Control", "Shift"]) + settings = page.get_by_text("Layer properties") + expect(settings).to_be_visible() + shape_settings = page.get_by_text("Shape properties") + expect(shape_settings).to_be_visible() + shape_settings.click() + define = page.locator(".umap-field-iconUrl .define") + undefine = page.locator(".umap-field-iconUrl .undefine") + expect(define).to_be_visible() + expect(undefine).to_be_hidden() + define.click() + symbols = page.locator(".umap-pictogram-choice") + expect(symbols).to_have_count(2) + search = page.locator(".umap-pictogram-body input") + search.type("circle") + expect(symbols).to_have_count(1) + symbols.click() + expect(marker).to_have_attribute("src", "/uploads/pictogram/circle.svg") + undefine.click() + expect(marker).to_have_attribute("src", "/uploads/pictogram/star.svg") + + +def test_can_change_picto_at_marker_level(map, live_server, page, pictos): + # Faster than doing a login + map.edit_status = Map.ANONYMOUS + map.settings["properties"]["iconUrl"] = "/uploads/pictogram/star.svg" + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA) + page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + marker = page.locator(".umap-div-icon img") + expect(marker).to_have_count(1) + # Should have default img + expect(marker).to_have_attribute("src", "/uploads/pictogram/star.svg") + # Edit marker + marker.click(modifiers=["Shift"]) + settings = page.get_by_text("Feature properties") + expect(settings).to_be_visible() + shape_settings = page.get_by_text("Shape properties") + expect(shape_settings).to_be_visible() + shape_settings.click() + define = page.locator(".umap-field-iconUrl .define") + undefine = page.locator(".umap-field-iconUrl .undefine") + expect(define).to_be_visible() + expect(undefine).to_be_hidden() + define.click() + symbols = page.locator(".umap-pictogram-choice") + expect(symbols).to_have_count(2) + search = page.locator(".umap-pictogram-body input") + search.type("circle") + expect(symbols).to_have_count(1) + symbols.click() + expect(marker).to_have_attribute("src", "/uploads/pictogram/circle.svg") + undefine.click() + expect(marker).to_have_attribute("src", "/uploads/pictogram/star.svg") + + +def test_can_use_remote_url_as_picto(map, live_server, page, pictos): + # Faster than doing a login + map.edit_status = Map.ANONYMOUS + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA) + page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + marker = page.locator(".umap-div-icon img") + expect(marker).to_have_count(1) + # Should have default img + expect(marker).to_have_attribute("src", "/static/umap/img/marker.png") + edit_settings = page.get_by_title("Edit map settings") + expect(edit_settings).to_be_visible() + edit_settings.click() + shape_settings = page.get_by_text("Default shape properties") + expect(shape_settings).to_be_visible() + shape_settings.click() + define = page.locator(".umap-field-iconUrl .define") + expect(define).to_be_visible() + define.click() + url_tab = page.get_by_role("button", name="URL") + input_el = page.get_by_placeholder("Add image URL") + expect(input_el).to_be_hidden() + expect(url_tab).to_be_visible() + url_tab.click() + expect(input_el).to_be_visible() + input_el.fill("https://foo.bar/img.jpg") + input_el.blur() + expect(marker).to_have_attribute("src", "https://foo.bar/img.jpg") + # Now close and reopen the form, it should still be the URL tab + close = page.locator("#umap-ui-container").get_by_title("Close") + expect(close).to_be_visible() + close.click() + edit_settings.click() + shape_settings.click() + modify = page.locator(".umap-field-iconUrl").get_by_text("Change") + expect(modify).to_be_visible() + modify.click() + # Should be on URL tab + expect(input_el).to_be_visible() + + +def test_can_use_char_as_picto(map, live_server, page, pictos): + # Faster than doing a login + map.edit_status = Map.ANONYMOUS + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA) + page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + marker = page.locator(".umap-div-icon span") + # Should have default img, so not a span + expect(marker).to_have_count(0) + edit_settings = page.get_by_title("Edit map settings") + expect(edit_settings).to_be_visible() + edit_settings.click() + shape_settings = page.get_by_text("Default shape properties") + expect(shape_settings).to_be_visible() + shape_settings.click() + define = page.locator(".umap-field-iconUrl .define") + define.click() + url_tab = page.get_by_role("button", name="Emoji & Character") + input_el = page.get_by_placeholder("Type char or paste emoji") + expect(input_el).to_be_hidden() + expect(url_tab).to_be_visible() + url_tab.click() + expect(input_el).to_be_visible() + input_el.fill("♩") + input_el.blur() + expect(marker).to_have_count(1) + expect(marker).to_have_text("♩") + # Now close and reopen the form, it should still be the URL tab + close = page.locator("#umap-ui-container").get_by_title("Close") + expect(close).to_be_visible() + close.click() + edit_settings.click() + shape_settings.click() + preview = page.locator(".umap-pictogram-choice") + expect(preview).to_be_visible() + preview.click() + # Should be on URL tab + expect(input_el).to_be_visible()