From abdd43f5e4b3172a8e41992c3df68b20c259ab77 Mon Sep 17 00:00:00 2001 From: Simen Abelsen Date: Mon, 20 Apr 2026 21:26:57 +0200 Subject: [PATCH 1/2] Replace Choices.js type-ahead filters with dropdown-based search --- changelog.d/1887.changed.md | 1 + src/argus/htmx/incident/filter.py | 31 +-- .../htmx/forms/checkbox_select_multiple.html | 2 +- .../htmx/forms/dropdown_select_multiple.html | 54 +++-- .../htmx/forms/search_select_multiple.html | 194 ++++++++++++------ 5 files changed, 188 insertions(+), 94 deletions(-) create mode 100644 changelog.d/1887.changed.md diff --git a/changelog.d/1887.changed.md b/changelog.d/1887.changed.md new file mode 100644 index 000000000..ed6ca6c12 --- /dev/null +++ b/changelog.d/1887.changed.md @@ -0,0 +1 @@ +Improve dropdown filter UX: fix tag selection, prevent flicker during updates, and keep search results visible when selecting multiple items diff --git a/src/argus/htmx/incident/filter.py b/src/argus/htmx/incident/filter.py index 423eafba4..4a7fea7df 100644 --- a/src/argus/htmx/incident/filter.py +++ b/src/argus/htmx/incident/filter.py @@ -147,8 +147,8 @@ def __init__(self, *args, **kwargs): # mollify tests partial_get = reverse("htmx:incident-filter") - self._init_source_field() - self._init_tag_field(*args, **kwargs) + self._init_source_field(partial_get) + self._init_tag_field(partial_get, *args, **kwargs) self.fields["source_types"].widget.partial_get = partial_get source_type_choices = SourceSystemType.objects.order_by("name").values_list("name", "name") @@ -161,29 +161,34 @@ def __init__(self, *args, **kwargs): self.fields["special_filters"].widget.partial_get = partial_get self.fields["special_filters"].choices = self.SPECIAL_FILTER_CHOICES - def _init_source_field(self): - """ - Initializes the 'sourceSystemIds' field widget for type-ahead search, - pre-loading all sources for client-side filtering. - """ - source_choices = SourceSystem.objects.order_by("name").values_list("id", "name") - self.fields["sourceSystemIds"].choices = tuple(source_choices) - - def _init_tag_field(self, *args, **kwargs): + def _init_tag_field(self, partial_get, *args, **kwargs): """ Initializes the 'tags' field widget and choices as key=value strings, and dynamically adds submitted tags. """ - self.fields["tags"].widget.partial_get = reverse("htmx:search-tags") + self.fields["tags"].widget.partial_get = partial_get + self.fields["tags"].widget.extra["search_url"] = reverse("htmx:search-tags") query_dict = args[0] if args else None if not query_dict: self.fields["tags"].choices = [] return - tags = query_dict.get("tags", []) + if hasattr(query_dict, "getlist"): + tags = query_dict.getlist("tags") + else: + tags = query_dict.get("tags", []) choices = [(tag, tag) for tag in tags] self.fields["tags"].choices = choices + def _init_source_field(self, partial_get): + """ + Initializes the 'sourceSystemIds' field widget for type-ahead search, + pre-loading all sources for client-side filtering. + """ + self.fields["sourceSystemIds"].widget.partial_get = partial_get + source_choices = SourceSystem.objects.order_by("name").values_list("id", "name") + self.fields["sourceSystemIds"].choices = tuple(source_choices) + def clean_tags(self): tags = self.cleaned_data["tags"] if not tags: diff --git a/src/argus/htmx/templates/htmx/forms/checkbox_select_multiple.html b/src/argus/htmx/templates/htmx/forms/checkbox_select_multiple.html index 40e75c8c5..21701375f 100644 --- a/src/argus/htmx/templates/htmx/forms/checkbox_select_multiple.html +++ b/src/argus/htmx/templates/htmx/forms/checkbox_select_multiple.html @@ -6,7 +6,7 @@ {% if widget.wrap_label %} {{ widget.label }} diff --git a/src/argus/htmx/templates/htmx/forms/dropdown_select_multiple.html b/src/argus/htmx/templates/htmx/forms/dropdown_select_multiple.html index 43e87fa50..d2ddc3b81 100644 --- a/src/argus/htmx/templates/htmx/forms/dropdown_select_multiple.html +++ b/src/argus/htmx/templates/htmx/forms/dropdown_select_multiple.html @@ -1,29 +1,43 @@ - diff --git a/src/argus/htmx/templates/htmx/forms/search_select_multiple.html b/src/argus/htmx/templates/htmx/forms/search_select_multiple.html index 395300368..83cbecfdb 100644 --- a/src/argus/htmx/templates/htmx/forms/search_select_multiple.html +++ b/src/argus/htmx/templates/htmx/forms/search_select_multiple.html @@ -1,75 +1,139 @@ - - + // Get currently selected values to avoid duplicates + const selected = new Set( + [...dropdown.querySelectorAll(`input[name="${fieldName}"]:checked`)] + .map(input => input.value) + ); + + preserveCheckedResults(); + resultsContainer.innerHTML = ''; + data.results.forEach(item => { + if (selected.has(String(item.id))) return; + + const label = document.createElement('label'); + label.className = 'flex gap-2 items-center my-1'; + + const input = document.createElement('input'); + input.type = 'checkbox'; + input.name = fieldName; + input.value = item.id; + input.className = 'checkbox checkbox-sm checkbox-primary border-base-content/20'; + input.autocomplete = 'off'; + + label.appendChild(input); + label.appendChild(document.createTextNode(' ' + item.text)); + resultsContainer.appendChild(label); + }); + } catch (error) { + resultsContainer.innerHTML = 'Search failed'; + } + }, 300); + }); + } + })(); + +{% endblock field_template %} From db0c9f2c1e42ecacd8174c0e8705e2068732bd32 Mon Sep 17 00:00:00 2001 From: Simen Abelsen Date: Mon, 27 Apr 2026 10:10:36 +0200 Subject: [PATCH 2/2] Differentiate dropdown trigger from search input in search dropdowns --- src/argus/htmx/incident/filter.py | 9 ++++----- src/argus/htmx/plannedmaintenance/views.py | 3 ++- .../htmx/forms/dropdown_select_multiple.html | 14 ++++++++++++-- .../htmx/forms/search_select_multiple.html | 2 +- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/argus/htmx/incident/filter.py b/src/argus/htmx/incident/filter.py index 4a7fea7df..9a22ca050 100644 --- a/src/argus/htmx/incident/filter.py +++ b/src/argus/htmx/incident/filter.py @@ -86,19 +86,18 @@ class IncidentFilterForm(forms.Form): ) sourceSystemIds = forms.MultipleChoiceField( widget=SearchDropdownMultiSelect( - attrs={"placeholder": "search sources..."}, + attrs={"placeholder": "Select sources..."}, partial_get=None, - extra={"preload": True, "display_name": "sources"}, + extra={"preload": True, "display_name": "sources", "search_placeholder": "Search sources..."}, ), required=False, label="Sources", ) tags = forms.MultipleChoiceField( widget=SearchDropdownMultiSelect( - attrs={ - "placeholder": "search tags...", - }, + attrs={"placeholder": "Select tags..."}, partial_get=None, + extra={"search_placeholder": "Search tags..."}, ), required=False, label="Tags", diff --git a/src/argus/htmx/plannedmaintenance/views.py b/src/argus/htmx/plannedmaintenance/views.py index 9162b78ae..f5bc08021 100644 --- a/src/argus/htmx/plannedmaintenance/views.py +++ b/src/argus/htmx/plannedmaintenance/views.py @@ -85,7 +85,8 @@ class FilterWidgetMixin: def get_filter_widget(self): return SearchDropdownMultiSelect( partial_get=reverse("htmx:search-filters"), - attrs={"placeholder": "Search by filter name or user..."}, + attrs={"placeholder": "Select filters..."}, + extra={"search_placeholder": "Search by filter name or user..."}, ) def get_form(self, form_class=None): diff --git a/src/argus/htmx/templates/htmx/forms/dropdown_select_multiple.html b/src/argus/htmx/templates/htmx/forms/dropdown_select_multiple.html index d2ddc3b81..a29fa32eb 100644 --- a/src/argus/htmx/templates/htmx/forms/dropdown_select_multiple.html +++ b/src/argus/htmx/templates/htmx/forms/dropdown_select_multiple.html @@ -1,5 +1,5 @@ -