Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions changelog.d/1958.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed planned maintenance filter selection freezing the drop-down
8 changes: 6 additions & 2 deletions src/argus/htmx/plannedmaintenance/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,14 @@ class FilterWidgetMixin:
"""Mixin to configure the filters field with SearchDropdownMultiSelect widget."""

def get_filter_widget(self):
search_url = reverse("htmx:search-filters")
return SearchDropdownMultiSelect(
partial_get=reverse("htmx:search-filters"),
partial_get=search_url,
attrs={"placeholder": "Select filters..."},
extra={"search_placeholder": "Search by filter name or user..."},
extra={
"search_placeholder": "Search by filter name or user...",
"search_url": search_url,
},
)

def get_form(self, form_class=None):
Expand Down
28 changes: 28 additions & 0 deletions src/argus/htmx/templates/htmx/forms/search_select_multiple.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<!-- htmx/forms/search_select_multiple.html -->
{% extends "htmx/forms/dropdown_select_multiple.html" %}
{% block field_control %}
id="dropdown-{{ widget.attrs.id }}"
{% endblock field_control %}
{% block show_selected %}
<span class="badge badge-primary">{{ option.label }}</span>
{% endblock show_selected %}
Expand All @@ -25,9 +28,31 @@
const preload = {{ widget.extra.preload|yesno:"true,false" }};
const searchUrl = '{{ widget.extra.search_url|default:"" }}';
const fieldName = '{{ widget.name }}';
const placeholder = '{{ widget.attrs.placeholder|default:"Select..." }}';
const arrowSvg = '<svg class="h-4 w-4 shrink-0 opacity-50 transition-transform duration-200 group-focus-within:rotate-180" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></svg>';

const resultsContainer = searchUrl ? dropdown.querySelector('[data-search-results]') : null;

function updateShowSelected() {
const showSelected = dropdown.querySelector('.show-selected-box');
const checked = [...dropdown.querySelectorAll(`input[name="${fieldName}"]:checked`)];

if (checked.length === 0) {
showSelected.innerHTML = `<span class="text-base-content/50 flex-1">${placeholder}</span>${arrowSvg}`;
} else {
const badges = checked.map(input => {
const label = input.closest('label');
const text = label ? label.textContent.trim() : input.value;
return `<span class="badge badge-primary">${text}</span>`;
}).join('');
showSelected.innerHTML = `
<span class="flex flex-wrap gap-1 flex-1 items-center overflow-y-auto max-h-14">${badges}</span>
<button type="button" class="cursor-pointer opacity-50 hover:opacity-100 flex-shrink-0" aria-label="Clear selection" data-clear-field="${fieldName}" onmousedown="event.preventDefault()">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
</button>`;
}
}

// Auto-focus search input when dropdown opens
dropdown.addEventListener('focusin', (e) => {
if (e.target.closest('.show-selected-box')) {
Expand Down Expand Up @@ -66,6 +91,9 @@
});
}

// Update the show-selected-box client-side on every checkbox change
dropdown.querySelector('.dropdown-content').addEventListener('change', updateShowSelected);

if (preload) {
// Client-side filtering for preloaded options
searchInput.addEventListener('input', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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();
}
})();
Expand Down
23 changes: 23 additions & 0 deletions tests/htmx/plannedmaintenance/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,29 @@ def test_cancel_view_redirects_on_success(self):
self.assertEqual(response.url, reverse("htmx:plannedmaintenance-list"))


@tag("integration")
class PlannedMaintenanceFilterWidgetTests(TestCase):
def setUp(self):
self.staff_user = AdminUserFactory()
now = timezone.now()
self.future_pm = PlannedMaintenanceFactory(
start_time=now + timedelta(days=1),
end_time=LOCAL_INFINITY,
)

def test_given_create_form_with_filters_field_it_should_have_search_url(self):
self.client.force_login(self.staff_user)
response = self.client.get(reverse("htmx:plannedmaintenance-create"))
widget = response.context["form"].fields["filters"].widget
self.assertIn("search_url", widget.extra)

def test_given_update_form_with_filters_field_it_should_have_search_url(self):
self.client.force_login(self.staff_user)
response = self.client.get(reverse("htmx:plannedmaintenance-update", kwargs={"pk": self.future_pm.pk}))
widget = response.context["form"].fields["filters"].widget
self.assertIn("search_url", widget.extra)


@tag("integration")
class SearchFiltersViewTests(TestCase):
def setUp(self):
Expand Down
Loading