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