From 6eb6be68b2fa6032e06c7c6045704b31aa4f0f7b Mon Sep 17 00:00:00 2001 From: George Katsikas <giorgakis.katsikas@gmail.com> Date: Tue, 9 Jul 2024 15:51:43 +0300 Subject: [PATCH] improve htmx dynsel widget --- scipost_django/common/forms.py | 47 +++++++- .../common/templates/htmx/dynsel.html | 31 +++--- .../templates/htmx/dynsel_list_page.html | 55 +++++---- scipost_django/common/urls.py | 7 +- scipost_django/common/views.py | 63 ++--------- .../static/scipost/assets/css/_common.scss | 104 +++++++++++++----- 6 files changed, 175 insertions(+), 132 deletions(-) diff --git a/scipost_django/common/forms.py b/scipost_django/common/forms.py index 4ee0c4376..e3cde9d00 100644 --- a/scipost_django/common/forms.py +++ b/scipost_django/common/forms.py @@ -2,6 +2,7 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" +import copy from django import forms from crispy_forms.helper import FormHelper from django.core.validators import EmailValidator @@ -31,14 +32,50 @@ class MultiEmailField(forms.CharField): ##### HTMX Class Based Forms ##### -class HTMXDynSelWidget(forms.Widget): +class HTMXDynSelWidget(forms.Select): template_name = "htmx/dynsel.html" - def __init__(self, *args, **kwargs): - self.dynsel_context = kwargs.pop("dynsel_context", {}) - super().__init__(*args, **kwargs) + def __init__(self, attrs=None, choices=(), **kwargs): + self.url = kwargs.pop("url", {}) + super().__init__(attrs, choices, **kwargs) + + self.attrs = self.attrs | { + "onclick": "return false;", + "onkeydown": "return false;", + "tabindex": "-1", + } def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) - context["dynsel_context"] = self.dynsel_context + context["url"] = self.url return context + + def filter_choices_to_render(self, selected_choices): + """Replace self.choices with selected_choices.""" + if hasattr(self.choices, "queryset"): + try: + self.choices.queryset = self.choices.queryset.filter( + pk__in=[c for c in selected_choices if c] + ) + except ValueError: + # if selected_choices are invalid, do nothing + pass + else: + self.choices = [c for c in self.choices if str(c[0]) in selected_choices] + + def optgroups(self, name, value, attrs=None): + """ + Exclude unselected self.choices before calling the parent method. + + Used by Django>=1.10. + """ + # Filter out None values, not needed for autocomplete + selected_choices = [str(c) for c in value if c] + all_choices = copy.copy(self.choices) + + self.filter_choices_to_render(selected_choices) + + result = super().optgroups(name, value, attrs) + self.choices = all_choices + + return result diff --git a/scipost_django/common/templates/htmx/dynsel.html b/scipost_django/common/templates/htmx/dynsel.html index 157d41a30..96a427c52 100644 --- a/scipost_django/common/templates/htmx/dynsel.html +++ b/scipost_django/common/templates/htmx/dynsel.html @@ -1,18 +1,17 @@ -<div id="hx-dynsel-{{ widget.name }}" class="hx-dynsel-container"> - <select name="{{ widget.name }}" - {% include "django/forms/widgets/attrs.html" %}></select> - <input id="hx-dynsel-{{ widget.name }}-q" - name="q" - value="{{ initial }}" - type="text" - hx-post="{{ dynsel_context.results_page_url }}" - hx-trigger="keyup changed delay:500ms" - hx-swap="innerHTML" - hx-target="next .hx-dynsel-result-list" /> - <div class="hx-dynsel-result-container"> - <ul id="hx-dynsel-{{ widget.name }}-results" - class="hx-dynsel-result-list"> - </ul> - <div id="{{ dynsel_context.collection_name }}-results-load-next"></div> +<div class="hx-dynsel"> + <div class="input-query-container"> + {% include "django/forms/widgets/select.html" %} + + <input name="q" + value="{{ initial }}" + type="text" + hx-post="{{ url }}" + hx-trigger="keyup changed delay:500ms" + hx-swap="innerHTML" + hx-target="next .result-list" /> </div> + + <ul class="result-list"> + </ul> + </div> diff --git a/scipost_django/common/templates/htmx/dynsel_list_page.html b/scipost_django/common/templates/htmx/dynsel_list_page.html index d7501aa0e..ea61e616c 100644 --- a/scipost_django/common/templates/htmx/dynsel_list_page.html +++ b/scipost_django/common/templates/htmx/dynsel_list_page.html @@ -1,41 +1,36 @@ +{% load scipost_extras %} + {% for obj in page_obj %} - <li class="hx-dynsel-result-list-item" + <li class="result-item" role="option" tabindex="1" - hx-get="{{ obj_select_option_url }}?pk={{ obj.pk }}" + hx-get="{% url "common:hx_dynsel_select_option" obj|content_type_id obj.id %}" hx-target="previous select" hx-swap="innerHTML" hx-trigger="click consume">{{ obj }}</li> -{% empty %} - <div id="{{ collection_name }}-results-load-next" hx-swap-oob="true"> - <li class="p-2 d-flex justify-content-center"> - <strong>No {{ model_name }} could be found</strong> - </li> - </div> {% endfor %} -{% if page_obj.has_next %} - <div id="{{ collection_name }}-results-load-next" - class="htmx-indicator" - hx-swap-oob="true" - hx-post="{{ request.path }}?page={{ page_obj.next_page_number }}" - hx-include="previous input" - hx-target="previous .hx-dynsel-result-list" - hx-trigger="intersect once" - hx-swap="beforeend" - hx-indicator="#{{ collection_name }}-results-load-next"> +<!-- Pagination --> - <li class="d-flex bg-primary bg-opacity-25 justify-content-center"> - <strong>Loading page {{ page_obj.next_page_number }} out of {{ page_obj.paginator.num_pages }}</strong> - <div class="spinner-grow spinner-grow-sm ms-2" - role="status" - aria-hidden="true"></div> - </li> - </div> +{% if page_obj.has_next %} + <li class="htmx-indicator w-100 d-flex justify-content-center bg-primary bg-opacity-25" + hx-post="{{ request.path }}?page={{ page_obj.next_page_number }}" + hx-include="previous input" + hx-trigger="intersect once" + hx-swap="outerHTML" + hx-target="this" + hx-indicator="closest div"> + <strong>Loading page {{ page_obj.next_page_number }} out of {{ page_obj.paginator.num_pages }}</strong> + <div class="spinner-grow spinner-grow-sm ms-2" + role="status" + aria-hidden="true"></div> + </li> +{% elif page_obj|length == 0 %} + <li class="w-100 d-flex justify-content-center"> + <strong>No {{ model_name }} could be found</strong> + </li> {% else %} - <div id="{{ collection_name }}-results-load-next" hx-swap-oob="true"> - <li class="d-flex justify-content-center"> - <strong>All {{ model_name }} loaded</strong> - </li> - </div> + <li class="w-100 d-flex justify-content-center"> + <strong>All {{ model_name }} loaded</strong> + </li> {% endif %} diff --git a/scipost_django/common/urls.py b/scipost_django/common/urls.py index 62d8ae697..6b682870d 100644 --- a/scipost_django/common/urls.py +++ b/scipost_django/common/urls.py @@ -13,5 +13,10 @@ urlpatterns = [ "empty", views.empty, name="empty", - ) + ), + path( + "hx_dynsel/select_option/<int:content_type_id>/<int:object_id>", + views.HXDynselSelectOptionView.as_view(), + name="hx_dynsel_select_option", + ), ] diff --git a/scipost_django/common/views.py b/scipost_django/common/views.py index 6a710ada5..daa3fea03 100644 --- a/scipost_django/common/views.py +++ b/scipost_django/common/views.py @@ -3,6 +3,7 @@ __license__ = "AGPL v3" from typing import Any, Dict from django.contrib import messages +from django.contrib.contenttypes.models import ContentType from django.core.paginator import Paginator from django.db.models.query import QuerySet from django.forms.forms import BaseForm @@ -145,30 +146,25 @@ class HTMXInlineCRUDModelListView(ListView): return context -class HXDynselSelectOptionView(SingleObjectMixin, View): - def get(self, request): - obj = self.get_object() +class HXDynselSelectOptionView(View): + def get(self, request, content_type_id, object_id): + obj = self.get_object(content_type_id, object_id) return HttpResponse( - format_html( - '<option value="{}" selected>{}</option>', - obj.pk, - str(obj), - ) + format_html('<option value="{}" selected>{}</option>', obj.pk, str(obj)) ) - def get_object(self): - queryset = self.model.objects.all() - pk = self.request.GET.get("pk") - return get_object_or_404(queryset, pk=pk) + def get_object(self, content_type_id, object_id): + model = ContentType.objects.get_for_id(content_type_id).model_class() + if model is None: + raise ValueError("Model not found") + return get_object_or_404(model, pk=object_id) -class HXDynselResultPage(View): +class HXDynselAutocomplete(View): model = None - collection_name = "results" template_name = "htmx/dynsel_list_page.html" paginate_by = 16 - obj_select_option_url = None def post(self, request): self.page_nr = request.GET.get("page") @@ -203,45 +199,8 @@ class HXDynselResultPage(View): def get_context_data(self, **kwargs): context = {} - context["collection_name"] = self.collection_name - context["obj_select_option_url"] = self.obj_select_option_url context["model_name"] = self.model._meta.verbose_name_plural context["q"] = self.q context["page_obj"] = self.get_page_obj(self.page_nr) return context - - -# def _hx_dynsel_organization_page(request): -# model = Organization -# queryset = model.objects.all() - -# collection_name = "organizations-list" - -# if q := request.POST.get("q", ""): -# queryset = queryset.filter( -# Q(name__unaccent__icontains=q) -# | Q(name_original__unaccent__icontains=q) -# | Q(acronym__unaccent__icontains=q) -# | Q(ror_json__names__contains=[{"value": q}]) # Search ROR -# ) - -# paginator = Paginator(queryset, 50) -# page_nr = request.GET.get("page") -# page_obj = paginator.get_page(page_nr) - -# context = { -# "page_obj": page_obj, -# "q": q, -# "model_name": model._meta.verbose_name_plural, -# "collection_name": collection_name, -# "obj_select_option_url": reverse( -# "organizations:organization-hx-dynsel-select-option" -# ), -# } - -# return TemplateResponse( -# request, -# "organizations/_hx_dynsel_organization_page.html", -# context, -# ) diff --git a/scipost_django/scipost/static/scipost/assets/css/_common.scss b/scipost_django/scipost/static/scipost/assets/css/_common.scss index 929d00613..19095a4ae 100644 --- a/scipost_django/scipost/static/scipost/assets/css/_common.scss +++ b/scipost_django/scipost/static/scipost/assets/css/_common.scss @@ -16,60 +16,108 @@ } -.hx-dynsel-container { +.hx-dynsel { margin-top: 0.5rem; margin-bottom: 0.5rem; position: relative; - &>select { - display: none; + select { + all: unset; + min-width: 10%; + text-wrap: nowrap; + text-overflow: ellipsis; + overflow: hidden; + pointer-events: none; + background-color: rgba(10, 10, 255, 0.1) + } + + ul { + margin-bottom: 0; } - &>input { + // Input Styling + .input-query-container { width: 100%; - padding: 0.5rem; border: 1px solid #ced4da; border-radius: 0.25rem; + height: 3em; + display: flex; + align-items: center; + gap: 0.5em; + padding: 0.5rem; + + >input { + flex: 1; + padding: 0.25em; + border: 0; + min-width: 10ch; + + &:focus, + &:focus-visible { + border: 0; + outline-color: unset; + } + } + + >.selected-items { + + &:empty { + display: none; + } + + >.selected-item { + background: rgba(10, 10, 255, 0.1); + padding: 0.25em; + overflow: hidden; + text-wrap: nowrap; + text-overflow: ellipsis; + flex-shrink: 1; + + &::after { + content: "x"; + color: red; + } + } + } } - &>.hx-dynsel-result-container { + // Results Styling + .result-list { display: none; position: absolute; top: 100%; margin-top: 0.5rem; background-color: white; - width: 100%; overflow-y: scroll; z-index: 1; max-height: 30vh; + width: 100%; + padding: 0.25rem; - &>.hx-dynsel-result-list:empty { + &:empty { display: none; } - &>.hx-dynsel-result-list { - width: 100%; - padding: 0.25rem; + >.result-item { + display: block; + padding: 0.1rem; - &>.hx-dynsel-result-list-item { - display: block; - - &:hover { - background-color: var(--bs-primary); - color: white; - cursor: pointer; - } + &:hover { + background-color: var(--bs-primary); + color: white; + cursor: pointer; } + } } -} -input:focus~.hx-dynsel-result-container, -.hx-dynsel-result-container:focus-within { - display: flex; - flex-direction: column; - align-items: center; - box-shadow: 0 0.5rem 1rem 0 rgba(108, 108, 108, 0.5); - border: 2px solid var(--bs-secondary); - border-radius: 0.25rem; + // Display results on focus + &:focus-within .result-list { + display: flex; + flex-direction: column; + box-shadow: 0 0.5rem 1rem 0 rgba(108, 108, 108, 0.5); + border: 2px solid var(--bs-secondary); + border-radius: 0.25rem; + } + } \ No newline at end of file -- GitLab