From 74bbaa266b2cb9bfda4dfed58ec382bc7f8c5985 Mon Sep 17 00:00:00 2001
From: George Katsikas <giorgakis.katsikas@gmail.com>
Date: Mon, 6 Nov 2023 18:42:33 +0100
Subject: [PATCH] add htmx crud inline classes

---
 scipost_django/common/forms.py                |   8 ++
 .../templates/htmx/htmx_inline_crud_form.html |  63 ++++++++++
 .../templates/htmx/htmx_inline_crud_list.html |   9 ++
 .../htmx/htmx_inline_crud_new_form.html       |  24 ++++
 scipost_django/common/views.py                | 114 ++++++++++++++++++
 .../static/scipost/assets/css/_common.scss    |  16 +++
 .../static/scipost/assets/css/style.scss      |   1 +
 .../scipost/templates/scipost/bare_base.html  |   2 +-
 8 files changed, 236 insertions(+), 1 deletion(-)
 create mode 100644 scipost_django/common/templates/htmx/htmx_inline_crud_form.html
 create mode 100644 scipost_django/common/templates/htmx/htmx_inline_crud_list.html
 create mode 100644 scipost_django/common/templates/htmx/htmx_inline_crud_new_form.html
 create mode 100644 scipost_django/scipost/static/scipost/assets/css/_common.scss

diff --git a/scipost_django/common/forms.py b/scipost_django/common/forms.py
index 03ca0afce..fdc1d996a 100644
--- a/scipost_django/common/forms.py
+++ b/scipost_django/common/forms.py
@@ -3,6 +3,14 @@ __license__ = "AGPL v3"
 
 
 from django import forms
+from crispy_forms.helper import FormHelper
+
+
+class HTMXInlineCRUDModelForm(forms.ModelForm):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.helper = FormHelper() if not hasattr(self, "helper") else self.helper
+        self.helper.form_tag = False
 
 
 class ModelChoiceFieldwithid(forms.ModelChoiceField):
diff --git a/scipost_django/common/templates/htmx/htmx_inline_crud_form.html b/scipost_django/common/templates/htmx/htmx_inline_crud_form.html
new file mode 100644
index 000000000..005e1822b
--- /dev/null
+++ b/scipost_django/common/templates/htmx/htmx_inline_crud_form.html
@@ -0,0 +1,63 @@
+{% load crispy_forms_tags %}
+
+<div id="{{ target_element_id }}" class="htmx-crud-element">
+
+  {% if form %}
+    <form id="{{ target_element_id }}-form">
+
+      {% if form.errors %}
+        <h1 class="text-danger">Warning: there was an error filling the voting form</h1>
+
+        {% for field in form %}
+
+          {% for error in field.errors %}
+            <div class="alert alert-danger">
+              <strong>{{ error|escape }}</strong>
+            </div>
+          {% endfor %}
+        {% endfor %}
+
+        {% for error in form.non_field_errors %}
+          <div class="alert alert-danger">
+            <strong>{{ error|escape }}</strong>
+          </div>
+        {% endfor %}
+
+      {% endif %}
+
+      <div class="d-flex justify-content-between align-items-center">
+        <div class="block w-100">{% crispy form %}</div>
+        <div id="{{ target_element_id }}-actions" class="htmx-crud-button-actions">
+          <button class="btn text-success"
+                  title="Save"
+                  hx-post="{{ view.request.path|add:"?&edit=1" }}"
+                  hx-target="#{{ target_element_id }}">{% include "bi/check-circle.html" %}</button>
+          <button class="btn text-secondary"
+                  title="Cancel"
+                  hx-get="{{ view.request.path }}"
+                  hx-target="#{{ target_element_id }}">{% include "bi/x-circle.html" %}</button>
+        </div>
+      </div>
+ 
+    </form>
+
+  {% else %}
+
+    <div class="d-flex justify-content-between align-items-center">
+      {% include ""|add:instance_li_template_name %}
+      <div id="{{ target_element_id }}-actions" class="htmx-crud-button-actions">
+        <button class="btn text-primary"
+                title="Edit"
+                hx-get="{{ view.request.path|add:"?&edit=1" }}"
+                hx-target="#{{ target_element_id }}">{% include "bi/pencil-square.html" %}</button>
+        <button class="btn text-danger"
+                title="Delete"
+                hx-confirm="Are you sure you want to delete this {{ instance_type|title }}?"
+                hx-delete="{{ view.request.path }}"
+                hx-target="#{{ target_element_id }}">{% include "bi/trash-fill.html" %}</button>
+      </div>
+    </div>
+
+  {% endif %}
+
+</div>
diff --git a/scipost_django/common/templates/htmx/htmx_inline_crud_list.html b/scipost_django/common/templates/htmx/htmx_inline_crud_list.html
new file mode 100644
index 000000000..feea76c28
--- /dev/null
+++ b/scipost_django/common/templates/htmx/htmx_inline_crud_list.html
@@ -0,0 +1,9 @@
+{% load crispy_forms_tags %}
+
+{% for object in object_list %}
+  <div hx-get="{{ object.model_form_view_url }}"
+       hx-swap="outerHTML"
+       hx-trigger="load"></div>
+{% endfor %}
+
+{% include "htmx/htmx_inline_crud_new_form.html" %}
diff --git a/scipost_django/common/templates/htmx/htmx_inline_crud_new_form.html b/scipost_django/common/templates/htmx/htmx_inline_crud_new_form.html
new file mode 100644
index 000000000..2fe6966c9
--- /dev/null
+++ b/scipost_django/common/templates/htmx/htmx_inline_crud_new_form.html
@@ -0,0 +1,24 @@
+{% load crispy_forms_tags %}
+
+<div id="{{ instance_type }}-new-container">
+ 
+
+  {% if add_form %}
+    <form id="{{ instance_type }}-new-form"
+          class="d-flex"
+          hx-post="{{ post_url }}"
+          hx-target="#{{ instance_type }}-new-container">
+      {% crispy add_form %}
+      <input type="submit" class="btn btn-sm btn-primary" />
+    </form>
+ 
+  {% else %}
+ 
+    <div class="d-flex justify-content-end m-2">
+      <button class="btn btn-sm btn-primary"
+              hx-post="{{ post_url }}"
+              hx-target="#{{ instance_type }}-new-container">Create</button>
+    </div>
+  {% endif %}
+
+</div>
diff --git a/scipost_django/common/views.py b/scipost_django/common/views.py
index 006780c0c..815180acd 100644
--- a/scipost_django/common/views.py
+++ b/scipost_django/common/views.py
@@ -1,7 +1,121 @@
 __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
 __license__ = "AGPL v3"
 
+from typing import Any, Dict
+from django.contrib import messages
+from django.db.models.query import QuerySet
+from django.forms.forms import BaseForm
 from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, render
+from django.template.response import TemplateResponse
+from django.urls import reverse
+from django.views.generic import CreateView, FormView, ListView
+from .forms import HTMXInlineCRUDModelForm
+
+
+class HTMXInlineCRUDModelFormView(FormView):
+    template_name = "htmx/htmx_inline_crud_form.html"
+    form_class = HTMXInlineCRUDModelForm
+    instance_li_template_name = None
+    target_element_id = "htmx-crud-{instance_type}-{instance_id}"
+    edit = False
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.instance_type = self.form_class.Meta.model.__name__.lower()
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["target_element_id"] = self.get_target_element_id()
+        context["instance_li_template_name"] = self.instance_li_template_name
+        context[self.instance_type] = context["instance"] = self.instance
+        return context
+
+    def post(self, request, *args, **kwargs):
+        self.instance = get_object_or_404(self.form_class.Meta.model, pk=kwargs["pk"])
+        self.edit = True
+        return super().post(request, *args, **kwargs)
+
+    def get(self, request, *args, **kwargs):
+        self.instance = get_object_or_404(self.form_class.Meta.model, pk=kwargs["pk"])
+        self.edit = bool(request.GET.get("edit", None))
+        super().get(request, *args, **kwargs)
+        return render(request, self.template_name, self.get_context_data(**kwargs))
+
+    def delete(self, request, *args, **kwargs):
+        self.instance = get_object_or_404(self.form_class.Meta.model, pk=kwargs["pk"])
+        self.instance.delete()
+        messages.success(
+            self.request, f"{self.instance_type.title()} deleted successfully"
+        )
+        return empty(request)
+
+    def get_form(self) -> BaseForm:
+        if self.request.method == "GET" and not self.edit:
+            return None
+        return super().get_form()
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs.update({"instance": self.instance})
+        return kwargs
+
+    def get_target_element_id(self) -> str:
+        return self.target_element_id.format(
+            instance_type=self.instance_type,
+            instance_id=self.instance.id,
+        )
+
+    def get_success_url(self) -> str:
+        return self.get_context_data()["view"].request.path
+
+    def form_valid(self, form: BaseForm) -> HttpResponse:
+        form.save()
+        messages.success(
+            self.request, f"{self.instance_type.title()} saved successfully"
+        )
+        return super().form_valid(form)
+
+
+class HTMXInlineCRUDModelListView(ListView):
+    template_name = "htmx/htmx_inline_crud_list.html"
+    add_form_class = None
+    model = None
+    model_form_view_url = None
+
+    def __init__(self, **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        self.instance_type = self.model.__name__.lower()
+
+    def _append_model_form_view_url(self, queryset: QuerySet) -> QuerySet:
+        for object in queryset:
+            object.model_form_view_url = reverse(
+                self.model_form_view_url, kwargs={"pk": object.pk}
+            )
+        return queryset
+
+    def post(self, request, *args, **kwargs):
+        if self.add_form_class is None:
+            return empty(request)
+        add_form = self.add_form_class(request.POST or None)
+        if add_form.is_valid():
+            add_form.save()
+            messages.success(self.request, f"{self.instance_type.title()} successfully")
+        return TemplateResponse(
+            request,
+            "htmx/htmx_inline_crud_new_form.html",
+            {
+                "post_url": request.path,
+                "add_form": add_form,
+                "instance_type": self.instance_type,
+            },
+        )
+
+    def get_context_data(self, **kwargs: Any):
+        context = super().get_context_data(**kwargs)
+        context["post_url"] = self.request.path
+        context["instance_type"] = self.instance_type
+        return context
 
 
 def empty(request):
diff --git a/scipost_django/scipost/static/scipost/assets/css/_common.scss b/scipost_django/scipost/static/scipost/assets/css/_common.scss
new file mode 100644
index 000000000..e080ee5d0
--- /dev/null
+++ b/scipost_django/scipost/static/scipost/assets/css/_common.scss
@@ -0,0 +1,16 @@
+.htmx-crud-button-actions {
+    display: flex;
+    flex-direction: column;
+    justify-content: space-evenly;
+    align-items: center;
+    margin-top: 0.5rem;
+    margin-bottom: 0.5rem;
+    visibility: hidden;
+}
+
+// set visibility of actions if any parent is hovered   
+.htmx-crud-element:hover {
+    & .htmx-crud-button-actions {
+        visibility: visible;
+    }
+}
\ No newline at end of file
diff --git a/scipost_django/scipost/static/scipost/assets/css/style.scss b/scipost_django/scipost/static/scipost/assets/css/style.scss
index 0d2964b21..823495e82 100644
--- a/scipost_django/scipost/static/scipost/assets/css/style.scss
+++ b/scipost_django/scipost/static/scipost/assets/css/style.scss
@@ -46,6 +46,7 @@
  *
  */
 @import "general";
+@import 'common';
 @import "colleges";
 @import "comments";
 @import "dynsel";
diff --git a/scipost_django/scipost/templates/scipost/bare_base.html b/scipost_django/scipost/templates/scipost/bare_base.html
index 6d6f980d8..010493803 100644
--- a/scipost_django/scipost/templates/scipost/bare_base.html
+++ b/scipost_django/scipost/templates/scipost/bare_base.html
@@ -24,7 +24,7 @@
     {% endblock headsup %}
   </head>
 
-  <body class="{% block body_class %}{% endblock %}">
+  <body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' class="{% block body_class %}{% endblock %}">
     {% block header %}
       {% include 'scipost/header.html' %}
     {% endblock header %}
-- 
GitLab