Merge pull request #1395 from umap-project/picto-ui

Refactor icon selector: use tabs, make options more explicit
This commit is contained in:
Yohan Boniface 2023-11-18 12:13:14 +01:00 committed by GitHub
commit 0db7f377c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 440 additions and 100 deletions

View file

@ -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;

View file

@ -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) {

View file

@ -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)
},
isUrl: function () {
return this.value() && this.value().indexOf('/') !== -1
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')
},
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')
},
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,
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'
},
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) {

View file

@ -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;

View file

@ -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 () {

View file

@ -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()

4
umap/tests/fixtures/circle.svg vendored Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" id="circle" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15">
<path d="M14,7.5c0,3.5899-2.9101,6.5-6.5,6.5S1,11.0899,1,7.5S3.9101,1,7.5,1S14,3.9101,14,7.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 254 B

4
umap/tests/fixtures/star.svg vendored Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" id="star" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15">
<path id="path4749-2-8-2" d="M7.5,0l-2,5h-5l4,3.5l-2,6l5-3.5&#xA;&#x9;l5,3.5l-2-6l4-3.5h-5L7.5,0z"/>
</svg>

After

Width:  |  Height:  |  Size: 256 B

View file

@ -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()