Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/1887.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve dropdown filter UX: fix tag selection, prevent flicker during updates, and keep search results visible when selecting multiple items
40 changes: 22 additions & 18 deletions src/argus/htmx/incident/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -147,8 +146,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")
Expand All @@ -161,29 +160,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:
Expand Down
3 changes: 2 additions & 1 deletion src/argus/htmx/plannedmaintenance/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<input type="{{ widget.type }}"
name="{{ widget.name }}"
autocomplete="off"
{% if widget.value != None %} class="checkbox checkbox-sm checkbox-primary border" value="{{ widget.value|stringformat:'s' }}"{% endif %}
{% if widget.value != None %} class="checkbox checkbox-sm checkbox-primary border-base-content/20" value="{{ widget.value|stringformat:'s' }}"{% endif %}
{% include "django/forms/widgets/attrs.html" %}>
{% if widget.wrap_label %}
{{ widget.label }}
Expand Down
64 changes: 49 additions & 15 deletions src/argus/htmx/templates/htmx/forms/dropdown_select_multiple.html
Original file line number Diff line number Diff line change
@@ -1,29 +1,53 @@
<!-- htmx/forms/dropdown_select_multiple.html -->
<div class="dropdown dropdown-bottom"
<div class="group dropdown dropdown-bottom max-w-sm"
{% block field_control %}
id="dropdown-{{ widget.attrs.id }}"
hx-trigger="change from:(find #{{ widget.attrs.id }})"
hx-trigger="change from:(find .dropdown-content)"
hx-swap="outerHTML"
hx-target="find .show-selected-box"
hx-select="#dropdown-{{ widget.attrs.id }} .show-selected-box"
hx-get="{{ widget.partial_get }}"
hx-include="find .dropdown-content"
hx-on::before-request="this.classList.add('dropdown-open')"
hx-on::after-settle="this.classList.remove('dropdown-open')"
{% endblock field_control %}>
<div tabindex="0"
role="button"
class="show-selected-box {{ widget.attrs.field_styles|default:"input input-primary h-auto" }} overflow-y-auto min-h-8 max-h-16 min-w-48 w-full leading-tight flex flex-wrap items-center gap-1">
{% if not widget.has_selected %}<span class="text-base-content/50">{{ widget.attrs.placeholder }}</span>{% endif %}
{% for _, options, _ in widget.optgroups %}
{% for option in options %}
{% if option.selected %}
{% block show_selected %}
<span class="text-primary font-medium">
<em>{{ option.label }}</em>,
</span>
{% endblock show_selected %}
{% endif %}
{% endfor %}
{% endfor %}
class="show-selected-box {{ widget.attrs.field_styles|default:"input input-primary h-auto" }} min-h-8 min-w-48 w-full leading-tight flex items-center gap-1">
{% if not widget.has_selected %}
<span class="text-base-content/50 flex-1">{{ widget.attrs.placeholder }}</span>
<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>
{% else %}
<span class="flex flex-wrap gap-1 flex-1 items-center overflow-y-auto max-h-14">
{% for _, options, _ in widget.optgroups %}
{% for option in options %}
{% if option.selected %}
{% block show_selected %}
<span class="text-primary font-medium">
<em>{{ option.label }}</em>,
</span>
{% endblock show_selected %}
{% endif %}
{% endfor %}
{% endfor %}
</span>
<button type="button"
class="cursor-pointer opacity-50 hover:opacity-100 flex-shrink-0"
aria-label="Clear selection"
data-clear-field="{{ widget.name }}"
onmousedown="event.preventDefault()">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
</button>
{% endif %}
</div>
<div tabindex="0"
class="dropdown-content bg-base-100 rounded-box z-1 min-w-fit w-full max-h-80 overflow-y-auto p-2 mt-0.5 shadow-sm">
Expand All @@ -33,4 +57,14 @@
{% include "django/forms/widgets/multiple_input.html" %}
{% endblock field_template %}
</div>
<script>
document.getElementById('dropdown-{{ widget.attrs.id }}').addEventListener('click', (e) => {
const btn = e.target.closest('[data-clear-field]');
if (!btn) return;
const dropdown = btn.closest('.dropdown');
const name = btn.dataset.clearField;
dropdown.querySelectorAll(`input[name="${name}"]:checked`).forEach(i => i.checked = false);
dropdown.querySelector('.dropdown-content').dispatchEvent(new Event('change', {bubbles: true}));
});
</script>
</div>
194 changes: 129 additions & 65 deletions src/argus/htmx/templates/htmx/forms/search_select_multiple.html
Original file line number Diff line number Diff line change
@@ -1,75 +1,139 @@
<!-- htmx/forms/search_select_multiple.html -->
<select id="id_{{ widget.name }}"
name="{{ widget.name }}"
class="hidden"
data-placeholder="{% if widget.attrs.placeholder %}{{ widget.attrs.placeholder }}{% else %}Search for {{ widget.name }}...{% endif %}"
multiple="true">
{% for _, options, _ in widget.optgroups %}
{% for option in options %}
{% if option.selected or widget.extra.preload %}
{% block show_selected %}
<option value="{{ option.value }}"
{% if option.selected %}selected="selected"{% endif %}>{{ option.label }}</option>
{% endblock show_selected %}
{% endif %}
{% endfor %}
{% endfor %}
</select>
<script>
(function () {
const selectId = '#id_{{ widget.name }}';
const parameterName = '{% if widget.extra.display_name %}{{ widget.extra.display_name }}{% else %}{{ widget.name }}{% endif %}';
const searchUrl = '{{ widget.partial_get }}';
{% extends "htmx/forms/dropdown_select_multiple.html" %}
{% block show_selected %}
<span class="badge badge-primary">{{ option.label }}</span>
{% endblock show_selected %}
{% block field_template %}
<input type="search"
class="input input-sm w-full mb-2"
placeholder="{{ widget.extra.search_placeholder|default:'Search...' }}"
data-search-input
autocomplete="off"
onchange="event.stopPropagation()"
onkeydown="if(event.key==='Enter'){event.preventDefault();event.stopPropagation()}">
{# Hidden input to ensure a value is always included in form submission #}
<input type="hidden" name="{{ widget.name }}" value="">
<div data-options-container class="max-h-64 overflow-y-auto">
{% include "django/forms/widgets/multiple_input.html" %}
</div>
{% if widget.extra.search_url %}<div data-search-results></div>{% endif %}
<script>
(function() {
const dropdown = document.getElementById('dropdown-{{ widget.attrs.id }}');
const searchInput = dropdown.querySelector('[data-search-input]');
const optionsContainer = dropdown.querySelector('[data-options-container]');
const preload = {{ widget.extra.preload|yesno:"true,false" }};
const searchUrl = '{{ widget.extra.search_url|default:"" }}';
const fieldName = '{{ widget.name }}';

const choices = new Choices(selectId, {
removeItemButton: true,
searchResultLimit: 10,
duplicateItemsAllowed: false,
shouldSort: false,
searchEnabled: true,
searchChoices: preload,
loadingText: 'Loading...',
noResultsText: `No ${parameterName} found`,
noChoicesText: `No ${parameterName} to choose from`,
itemSelectText: '',
searchFloor: preload ? 1 : 2,
fuseOptions: {
threshold: 0.4,
ignoreLocation: true,
},
removeItemIconText: () => '',
classNames: {
containerOuter: ['choices', 'dropdown', 'max-w-sm', 'self-center'],
containerInner: ['choices__inner'],
listDropdown: ['choices-dropdown'],
listItems: ['choices-list-items'],
}
const resultsContainer = searchUrl ? dropdown.querySelector('[data-search-results]') : null;

// Auto-focus search input when dropdown opens
dropdown.addEventListener('focusin', (e) => {
if (e.target.closest('.show-selected-box')) {
setTimeout(() => searchInput.focus(), 50);
}
});

// Reset search state when dropdown closes
dropdown.addEventListener('focusout', (e) => {
if (dropdown.contains(e.relatedTarget)) return;
searchInput.value = '';
if (preload) {
optionsContainer.querySelectorAll('label').forEach(label => {
label.style.display = '';
});
}
if (resultsContainer) {
preserveCheckedResults();
resultsContainer.innerHTML = '';
// Remove unchecked items that were added from search
optionsContainer.querySelectorAll('[data-from-search]').forEach(label => {
if (!label.querySelector('input:checked')) label.remove();
});
}
});

if (preload) return;
// Move checked search results into persistent options before replacing
function preserveCheckedResults() {
if (!resultsContainer) return;
resultsContainer.querySelectorAll('input:checked').forEach(input => {
const label = input.closest('label');
if (label) {
label.dataset.fromSearch = 'true';
optionsContainer.appendChild(label);
}
});
}

if (preload) {
// Client-side filtering for preloaded options
searchInput.addEventListener('input', () => {
const query = searchInput.value.toLowerCase();
optionsContainer.querySelectorAll('label').forEach(label => {
const text = label.textContent.toLowerCase();
label.style.display = text.includes(query) ? '' : 'none';
});
});
} else if (searchUrl) {
let searchTimeout;

const choicesElement = choices.passedElement.element;
// Remove all search-result items when selections are fully cleared
dropdown.querySelector('.dropdown-content').addEventListener('change', () => {
if (!dropdown.querySelector(`input[name="${fieldName}"]:checked`)) {
optionsContainer.querySelectorAll('[data-from-search]').forEach(label => label.remove());
resultsContainer.innerHTML = '';
}
});

let searchTimeout;
choicesElement.addEventListener('search', (event) => {
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
const query = searchInput.value.trim();

if (query.length < 2) {
preserveCheckedResults();
resultsContainer.innerHTML = '';
return;
}

searchTimeout = setTimeout(async () => {
try {
const searchTerm = event.detail.value;
const response = await fetch(
`${searchUrl}?q=${encodeURIComponent(searchTerm)}`
);
const options = await response.json();
choices.setChoices(options.results, 'id', 'text', true);
} catch (error) {
choices.setChoices([{value: 'error', label: `Failed to load ${parameterName}`}]);
}
}, 500);
});
try {
const response = await fetch(
`${searchUrl}?q=${encodeURIComponent(query)}`
);
const data = await response.json();

choicesElement.addEventListener('change', function (event) {
choices.setChoices([], 'id', 'text', true);
});
})();
</script>
// 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 = '<span class="text-error text-sm">Search failed</span>';
}
}, 300);
});
}
})();
</script>
{% endblock field_template %}
Loading