Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Map.tags and allow to edit from client #2530

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/config/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,10 @@ CREATE EXTENSION btree_gin;
ALTER TEXT SEARCH CONFIGURATION umapdict ALTER MAPPING FOR hword, hword_part, word WITH unaccent, simple;

# Now create the index
CREATE INDEX IF NOT EXISTS search_idx ON umap_map USING GIN(to_tsvector('umapdict', COALESCE(name, ''::character varying)::text), share_status);
CREATE INDEX IF NOT EXISTS search_idx ON umap_map USING GIN(to_tsvector('umapdict', COALESCE(name, ''::character varying)::text), share_status, tags);

# You should also create an index for tag filtering:
CREATE INDEX IF NOT EXISTS tags_idx ON umap_map USING GIN(share_status, tags);
```

Then set:
Expand Down
1 change: 1 addition & 0 deletions umap/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def settings(request):
"UMAP_DEMO_SITE": djsettings.UMAP_DEMO_SITE,
"UMAP_HOST_INFOS": djsettings.UMAP_HOST_INFOS,
"UMAP_ALLOW_EDIT_PROFILE": djsettings.UMAP_ALLOW_EDIT_PROFILE,
"UMAP_TAGS": djsettings.UMAP_TAGS,
}


Expand Down
3 changes: 1 addition & 2 deletions umap/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from django.contrib.gis.geos import Point
from django.forms.utils import ErrorList
from django.template.defaultfilters import slugify
from django.utils.translation import gettext_lazy as _

from .models import DataLayer, Map, Team

Expand Down Expand Up @@ -92,7 +91,7 @@ def clean_center(self):
return self.cleaned_data["center"]

class Meta:
fields = ("settings", "name", "center", "slug")
fields = ("settings", "name", "center", "slug", "tags")
model = Map


Expand Down
23 changes: 23 additions & 0 deletions umap/migrations/0027_map_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-02-26 16:18

import django.contrib.postgres.fields
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("umap", "0026_datalayer_modified_at_datalayer_share_status"),
]

operations = [
migrations.AddField(
model_name="map",
name="tags",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=200),
blank=True,
default=list,
size=None,
),
),
]
5 changes: 4 additions & 1 deletion umap/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.gis.db import models
from django.contrib.postgres.fields import ArrayField
from django.core.files.base import File
from django.core.files.storage import storages
from django.core.signing import Signer
Expand Down Expand Up @@ -236,6 +237,7 @@ class Map(NamedModel):
settings = models.JSONField(
blank=True, null=True, verbose_name=_("settings"), default=dict
)
tags = ArrayField(models.CharField(max_length=200), blank=True, default=list)

objects = models.Manager()
public = PublicManager()
Expand Down Expand Up @@ -420,7 +422,8 @@ def extra_schema(self):
return {
"iconUrl": {
"default": "%sumap/img/marker.svg" % settings.STATIC_URL,
}
},
"tags": {"choices": settings.UMAP_TAGS},
}


Expand Down
14 changes: 14 additions & 0 deletions umap/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import environ
from django.conf.locale import LANG_INFO
from django.utils.translation import gettext_lazy as _

import umap as project_module

Expand Down Expand Up @@ -289,6 +290,19 @@
UMAP_IMPORTERS = {}
UMAP_HOST_INFOS = {}
UMAP_LABEL_KEYS = ["name", "title"]
UMAP_TAGS = (
("art", _("Art and Culture")),
("bike", _("Bike")),
("environment", _("Environment")),
("education", _("Education")),
("food", _("Food and Agriculture")),
("history", _("History")),
("public", _("Public sector")),
("sport", _("Sport and Leisure")),
("travel", _("Travel")),
("trekking", _("Trekking")),
("tourism", _("Tourism")),
)

UMAP_READONLY = env("UMAP_READONLY", default=False)
UMAP_GZIP = True
Expand Down
2 changes: 2 additions & 0 deletions umap/static/umap/js/modules/form/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ export class MutatingForm extends Form {
} else if (properties.type === Number) {
if (properties.step) properties.handler = 'Range'
else properties.handler = 'IntInput'
} else if (properties.type === Array) {
properties.handler = 'CheckBoxes'
} else if (properties.choices) {
const text_length = properties.choices.reduce(
(acc, [_, label]) => acc + label.length,
Expand Down
23 changes: 21 additions & 2 deletions umap/static/umap/js/modules/form/fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,24 @@ Fields.CheckBox = class extends BaseElement {
}
}

Fields.CheckBoxes = class extends BaseElement {
build() {
const initial = this.get() || []
for (const [value, label] of this.properties.choices) {
const tpl = `<label><input type=checkbox value="${value}" name="${this.name}" data-ref=input />${label}</label>`
const [root, { input }] = Utils.loadTemplateWithRefs(tpl)
this.container.appendChild(root)
input.checked = initial.includes(value)
input.addEventListener('change', () => this.sync())
}
super.build()
}

value() {
return Array.from(this.root.querySelectorAll('input:checked')).map((el) => el.value)
}
}

Fields.Select = class extends BaseElement {
getTemplate() {
return `<select name="${this.name}" data-ref=select></select>`
Expand Down Expand Up @@ -1296,12 +1314,13 @@ Fields.ManageEditors = class extends BaseElement {
placeholder: translate("Type editor's username"),
}
this.autocomplete = new AjaxAutocompleteMultiple(this.container, options)
this._values = this.toHTML()
if (this._values)
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() {
Expand Down
3 changes: 3 additions & 0 deletions umap/static/umap/js/modules/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,9 @@ export const SCHEMA = {
helpEntries: ['sync'],
default: false,
},
tags: {
type: Array,
},
tilelayer: {
type: Object,
impacts: ['background'],
Expand Down
7 changes: 7 additions & 0 deletions umap/static/umap/js/modules/umap.js
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,12 @@ export default class Umap extends ServerStored {
const form = builder.build()
container.appendChild(form)

const tags = DomUtil.createFieldset(container, translate('Tags'))
const tagsFields = ['properties.tags']
const tagsBuilder = new MutatingForm(this, tagsFields, {
umap: this,
})
tags.appendChild(tagsBuilder.build())
const credits = DomUtil.createFieldset(container, translate('Credits'))
const creditsFields = [
'properties.licence',
Expand Down Expand Up @@ -1185,6 +1191,7 @@ export default class Umap extends ServerStored {
const formData = new FormData()
formData.append('name', this.properties.name)
formData.append('center', JSON.stringify(this.geometry()))
formData.append('tags', this.properties.tags || [])
formData.append('settings', JSON.stringify(geojson))
const uri = this.urls.get('map_save', { map_id: this.id })
const [data, _, error] = await this.server.post(uri, {}, formData)
Expand Down
12 changes: 10 additions & 2 deletions umap/templates/umap/search_bar.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@
<div class="wrapper search_wrapper">
<div class="row">
<form action="{% firstof action search_url %}" method="get">
<div class="col two-third mwide">
<div class="col half mwide">
<input name="q"
type="search"
placeholder="{% firstof placeholder default_placeholder %}"
value="{{ request.GET.q|default:"" }}" />
</div>
<div class="col third mwide">
<div class="col quarter mwide">
<select name="tags">
<option value="">{% trans "Any category" %}</option>
{% for value, label in UMAP_TAGS %}
<option value="{{ value }}" {% if request.GET.tags == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col quarter mwide">
<input type="submit" value="{% trans "Search" %}" class="neutral" />
</div>
</form>
Expand Down
15 changes: 15 additions & 0 deletions umap/tests/integration/test_edit_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,18 @@ def test_hover_tooltip_setting_should_be_persistent(live_server, map, page):
- text: always never on hover
""")
expect(page.locator(".umap-field-showLabel input[value=null]")).to_be_checked()


def test_can_edit_map_tags(live_server, map, page):
map.settings["properties"]["tags"] = ["art"]
map.edit_status = Map.ANONYMOUS
map.save()
page.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
page.get_by_role("button", name="Edit map name and caption").click()
page.get_by_text("Tags").click()
expect(page.get_by_label("Art and Culture")).to_be_checked()
page.get_by_label("Bike").check()
with page.expect_response(re.compile("./update/settings/.*")):
page.get_by_role("button", name="Save").click()
saved = Map.objects.get(pk=map.pk)
assert saved.tags == ["art", "bike"]
24 changes: 24 additions & 0 deletions umap/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,3 +476,27 @@ def test_cannot_search_deleted_map(client, map):
url = reverse("search")
response = client.get(url + "?q=Blé")
assert "Blé dur" not in response.content.decode()


@pytest.mark.django_db
def test_filter_by_tag(client, map):
# Very basic search, that do not deal with accent nor case.
# See install.md for how to have a smarter dict + index.
map.name = "Blé dur"
map.tags = ["bike"]
map.save()
url = reverse("search")
response = client.get(url + "?tags=bike")
assert "Blé dur" in response.content.decode()


@pytest.mark.django_db
def test_can_combine_search_and_filter(client, map):
# Very basic search, that do not deal with accent nor case.
# See install.md for how to have a smarter dict + index.
map.name = "Blé dur"
map.tags = ["bike"]
map.save()
url = reverse("search")
response = client.get(url + "?q=dur&tags=bike")
assert "Blé dur" in response.content.decode()
8 changes: 7 additions & 1 deletion umap/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,12 +333,18 @@ def get_context_data(self, **kwargs):
class SearchMixin:
def get_search_queryset(self, **kwargs):
q = self.request.GET.get("q")
tags = [t for t in self.request.GET.getlist("tags") if t]
qs = Map.objects.all()
if q:
vector = SearchVector("name", config=settings.UMAP_SEARCH_CONFIGURATION)
query = SearchQuery(
q, config=settings.UMAP_SEARCH_CONFIGURATION, search_type="websearch"
)
return Map.objects.annotate(search=vector).filter(search=query)
qs = qs.annotate(search=vector).filter(search=query)
if tags:
qs = qs.filter(tags__contains=tags)
if q or tags:
return qs


class Search(PaginatorMixin, TemplateView, PublicMapsMixin, SearchMixin):
Expand Down
Loading