diff --git a/changelog.d/1958.fixed.md b/changelog.d/1958.fixed.md new file mode 100644 index 000000000..37f2ab040 --- /dev/null +++ b/changelog.d/1958.fixed.md @@ -0,0 +1 @@ +Fixed planned maintenance filter selection freezing the drop-down diff --git a/src/argus/htmx/plannedmaintenance/urls.py b/src/argus/htmx/plannedmaintenance/urls.py index e023237d0..ce6409e23 100644 --- a/src/argus/htmx/plannedmaintenance/urls.py +++ b/src/argus/htmx/plannedmaintenance/urls.py @@ -6,7 +6,7 @@ app_name = "htmx" urlpatterns = [ path("create/", views.PlannedMaintenanceCreateView.as_view(), name="plannedmaintenance-create"), - path("search-filters/", views.SearchFiltersView.as_view(), name="search-filters"), + path("filter-widget/", views.FilterWidgetPartialView.as_view(), name="plannedmaintenance-filter-widget"), path("filter-preview/", views.FilterPreviewView.as_view(), name="plannedmaintenance-filter-preview"), re_path(r"^(?Pupcoming|past)?/$", views.PlannedMaintenanceListView.as_view(), name="plannedmaintenance-list"), path("/cancel/", views.PlannedMaintenanceCancelView.as_view(), name="plannedmaintenance-cancel"), diff --git a/src/argus/htmx/plannedmaintenance/views.py b/src/argus/htmx/plannedmaintenance/views.py index f5bc08021..826728c53 100644 --- a/src/argus/htmx/plannedmaintenance/views.py +++ b/src/argus/htmx/plannedmaintenance/views.py @@ -1,8 +1,8 @@ from django import forms from django.core.exceptions import PermissionDenied -from django.db.models import Q +from django.db.models import Case, IntegerField, When from django.forms import modelform_factory -from django.http import HttpResponseRedirect, JsonResponse +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render from django.urls import reverse from django.utils import timezone @@ -84,15 +84,22 @@ class FilterWidgetMixin: def get_filter_widget(self): return SearchDropdownMultiSelect( - partial_get=reverse("htmx:search-filters"), + partial_get=reverse("htmx:plannedmaintenance-filter-widget"), attrs={"placeholder": "Select filters..."}, - extra={"search_placeholder": "Search by filter name or user..."}, + extra={ + "search_placeholder": "Search by filter name or user...", + "preload": True, + }, ) def get_form(self, form_class=None): form = super().get_form(form_class) if "filters" in form.fields: - form.fields["filters"].queryset = Filter.objects.select_related("user") + form.fields["filters"].queryset = ( + Filter.objects.select_related("user") + .annotate(own=Case(When(user=self.request.user, then=0), default=1, output_field=IntegerField())) + .order_by("own", "name") + ) form.fields["filters"].label_from_instance = lambda obj: f"{obj.name} ({obj.user.username})" if "end_time" in form.fields: form.fields["end_time"].required = False @@ -180,25 +187,23 @@ def form_valid(self, form): return super().form_valid(form) -class SearchFiltersView(UserIsStaffMixin, View): +class FilterWidgetPartialView(UserIsStaffMixin, View): http_method_names = ["get"] def get(self, request): - query = request.GET.get("q", "") - - filters = Filter.objects.select_related("user") - if query: - filters = filters.filter( - Q(name__icontains=query) - | Q(user__username__icontains=query) - | Q(user__first_name__icontains=query) - | Q(user__last_name__icontains=query) - ) - filters = sorted(filters, key=lambda f: (f.user != request.user, f.name))[:20] + filter_ids = [fid for fid in request.GET.getlist("filters") if fid.isdigit()] + selected_filters = Filter.objects.filter(pk__in=filter_ids).select_related("user") - options = [{"id": f.pk, "text": f"{f.name} ({f.user.username})"} for f in filters] + class FiltersForm(forms.Form): + filters = forms.ModelMultipleChoiceField( + queryset=selected_filters, + widget=FilterWidgetMixin().get_filter_widget(), + required=False, + ) - return JsonResponse({"results": options}) + form = FiltersForm(request.GET) + form.fields["filters"].label_from_instance = lambda obj: f"{obj.name} ({obj.user.username})" + return HttpResponse(str(form["filters"])) class FilterPreviewView(UserIsStaffMixin, View): 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 cd1878599..e821cccaf 100644 --- a/src/argus/htmx/templates/htmx/forms/search_select_multiple.html +++ b/src/argus/htmx/templates/htmx/forms/search_select_multiple.html @@ -67,12 +67,20 @@ } if (preload) { - // Client-side filtering for preloaded options + // Client-side filtering, checked items always stay visible, unchecked results capped at 20 searchInput.addEventListener('input', () => { const query = searchInput.value.toLowerCase(); + let shown = 0; optionsContainer.querySelectorAll('label').forEach(label => { - const text = label.textContent.toLowerCase(); - label.style.display = text.includes(query) ? '' : 'none'; + const isChecked = label.querySelector('input:checked'); + if (isChecked) { + label.style.display = ''; + } else if (label.textContent.toLowerCase().includes(query) && shown < 20) { + label.style.display = ''; + shown++; + } else { + label.style.display = 'none'; + } }); }); } else if (searchUrl) { diff --git a/src/argus/htmx/templates/htmx/plannedmaintenance/_plannedmaintenance_form_script.html b/src/argus/htmx/templates/htmx/plannedmaintenance/_plannedmaintenance_form_script.html index 681d47018..05e153b2f 100644 --- a/src/argus/htmx/templates/htmx/plannedmaintenance/_plannedmaintenance_form_script.html +++ b/src/argus/htmx/templates/htmx/plannedmaintenance/_plannedmaintenance_form_script.html @@ -28,14 +28,15 @@ // Filter preview functionality (() => { const filterPreview = document.getElementById('filter-preview'); - const filtersSelect = document.getElementById('id_filters'); - if (!filterPreview || !filtersSelect) return; + const filtersDropdown = document.getElementById('dropdown-id_filters'); + if (!filterPreview || !filtersDropdown) return; const previewUrl = '{% url "htmx:plannedmaintenance-filter-preview" %}'; let debounceTimer = null; const loadPreview = () => { - const filterIds = Array.from(filtersSelect.selectedOptions).map(opt => opt.value); + const filterIds = [...filtersDropdown.querySelectorAll('input[name="filters"]:checked')] + .map(input => input.value); const params = new URLSearchParams(); filterIds.forEach(id => params.append('filters', id)); @@ -46,14 +47,13 @@ }); }; - // Listen for changes on the select element (Choices.js triggers change events) - filtersSelect.addEventListener('change', () => { + filtersDropdown.querySelector('.dropdown-content').addEventListener('change', () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(loadPreview, 500); }); // Load initial preview if filters are already selected - if (filtersSelect.selectedOptions.length > 0) { + if (filtersDropdown.querySelector('input[name="filters"]:checked')) { loadPreview(); } })(); diff --git a/src/argus/htmx/templates/htmx/plannedmaintenance/plannedmaintenance_form.html b/src/argus/htmx/templates/htmx/plannedmaintenance/plannedmaintenance_form.html index 8271f2e1a..cb6a9e935 100644 --- a/src/argus/htmx/templates/htmx/plannedmaintenance/plannedmaintenance_form.html +++ b/src/argus/htmx/templates/htmx/plannedmaintenance/plannedmaintenance_form.html @@ -113,7 +113,7 @@

{{ form.filters }}
- Select filters that define incidents covered by this maintenance. Filters should include source system IDs, source types, or tags to be effective. Type at least 2 characters to search. + Select filters that define incidents covered by this maintenance. Filters should include source system IDs, source types, or tags to be effective.
{% if form.filters.errors %}
diff --git a/tests/htmx/plannedmaintenance/test_views.py b/tests/htmx/plannedmaintenance/test_views.py index 3d0984fe6..beaed842e 100644 --- a/tests/htmx/plannedmaintenance/test_views.py +++ b/tests/htmx/plannedmaintenance/test_views.py @@ -271,55 +271,39 @@ def test_cancel_view_redirects_on_success(self): @tag("integration") -class SearchFiltersViewTests(TestCase): +class PlannedMaintenanceFilterDropdownTests(TestCase): def setUp(self): - self.user = PersonUserFactory() - self.staff_user = AdminUserFactory(username="felaali", first_name="Ferrari", last_name="Testarossa") - self.other_staff_user = AdminUserFactory(username="lambo", first_name="Lamborghini", last_name="Countach") - - self.staff_filter = FilterFactory(user=self.staff_user, name="My Filter") - self.other_staff_filter = FilterFactory(user=self.other_staff_user, name="Other Filter") - - def test_search_filters_requires_login(self): - response = self.client.get(reverse("htmx:search-filters")) - self.assertEqual(response.status_code, 302) - - def test_search_filters_when_not_staff_then_forbidden(self): - self.client.force_login(self.user) - response = self.client.get(reverse("htmx:search-filters")) - self.assertEqual(response.status_code, 403) - - def test_search_filters_it_should_return_json(self): - self.client.force_login(self.staff_user) - response = self.client.get(reverse("htmx:search-filters")) - self.assertEqual(response.status_code, 200) - self.assertEqual(response["Content-Type"], "application/json") - - def test_search_filters_returns_all_filters_without_query(self): - self.client.force_login(self.staff_user) - response = self.client.get(reverse("htmx:search-filters")) - data = response.json() - self.assertEqual(len(data["results"]), 2) + self.staff_user = AdminUserFactory() - def test_search_filters_filters_by_name(self): + def test_given_create_form_filter_queryset_should_include_all_filters(self): self.client.force_login(self.staff_user) - response = self.client.get(reverse("htmx:search-filters"), {"q": "My"}) - data = response.json() - self.assertEqual(len(data["results"]), 1) - self.assertEqual(data["results"][0]["id"], self.staff_filter.pk) + f1 = FilterFactory(user=self.staff_user) + f2 = FilterFactory(user=AdminUserFactory()) + response = self.client.get(reverse("htmx:plannedmaintenance-create")) + qs = response.context["form"].fields["filters"].queryset + self.assertIn(f1, qs) + self.assertIn(f2, qs) - def test_search_filters_filters_by_username(self): + def test_given_create_form_queryset_users_own_filters_come_first(self): self.client.force_login(self.staff_user) - response = self.client.get(reverse("htmx:search-filters"), {"q": self.other_staff_user.username}) - data = response.json() - self.assertEqual(len(data["results"]), 1) - self.assertEqual(data["results"][0]["id"], self.other_staff_filter.pk) + other_user_1 = AdminUserFactory() + other_user_2 = AdminUserFactory() + + FilterFactory(user=other_user_1, name="B other") + FilterFactory(user=self.staff_user, name="M own") + FilterFactory(user=self.staff_user, name="A own") + FilterFactory(user=other_user_2, name="Z other") + FilterFactory(user=other_user_2, name="C other") + response = self.client.get(reverse("htmx:plannedmaintenance-create")) + names = [f.name for f in response.context["form"].fields["filters"].queryset] + self.assertEqual(names, ["A own", "M own", "B other", "C other", "Z other"]) - def test_search_filters_sorts_current_user_filters_first(self): + def test_given_selected_filter_ids_partial_view_it_should_render_them_as_checked(self): self.client.force_login(self.staff_user) - response = self.client.get(reverse("htmx:search-filters")) - data = response.json() - self.assertEqual(data["results"][0]["id"], self.staff_filter.pk) + f = FilterFactory(user=self.staff_user) + response = self.client.get(reverse("htmx:plannedmaintenance-filter-widget"), {"filters": [f.pk]}) + self.assertContains(response, f'value="{f.pk}"') + self.assertContains(response, "checked") @tag("integration")