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