use HTMX for club list page search

This commit is contained in:
imperosol
2026-03-27 21:52:21 +01:00
parent 842ae5615d
commit 26178f10c5
4 changed files with 112 additions and 125 deletions

View File

@@ -315,3 +315,22 @@ class JoinClubForm(ClubMemberForm):
_("You are already a member of this club"), code="invalid" _("You are already a member of this club"), code="invalid"
) )
return super().clean() return super().clean()
class ClubSearchForm(forms.ModelForm):
class Meta:
model = Club
fields = ["name"]
widgets = {"name": forms.SearchInput(attrs={"autocomplete": "off"})}
club_status = forms.NullBooleanField(
label=_("Club status"),
widget=forms.RadioSelect(
choices=[(True, _("Active")), (False, _("Inactive")), ("", _("All clubs"))],
),
initial=True,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["name"].required = False

View File

@@ -1,63 +0,0 @@
import { History, updateQueryString } from "#core:utils/history";
import {
type ClubProfileSchema,
type ClubSearchClubData,
clubSearchClubProfile,
type Options,
} from "#openapi";
type ClubStatus = "active" | "inactive" | "both";
const PAGE_SIZE = 50;
document.addEventListener("alpine:init", () => {
Alpine.data("clubList", () => ({
clubName: "",
clubStatus: "active" as ClubStatus,
currentPage: 1,
nbPages: 1,
clubs: [] as ClubProfileSchema[],
loading: false,
async init() {
const urlParams = new URLSearchParams(window.location.search);
this.clubName = urlParams.get("clubName") || "";
this.clubStatus = urlParams.get("clubStatus") || "active";
this.currentPage = urlParams.get("currentPage") || 1;
for (const param of ["clubName", "clubStatus", "currentPage"]) {
this.$watch(param, async (value: number | string) => {
updateQueryString(param, value.toString(), History.Replace);
await this.loadClubs();
});
}
await this.loadClubs();
},
async loadClubs() {
this.loading = true;
const searchParams: Options<ClubSearchClubData> = {
query: { page: this.currentPage },
};
if (this.clubName) {
searchParams.query.search = this.clubName;
}
if (this.clubStatus === "active") {
searchParams.query.is_active = true;
} else if (this.clubStatus === "inactive") {
searchParams.query.is_active = false;
}
const res = await clubSearchClubProfile(searchParams);
this.nbPages = Math.ceil(res.data.count / PAGE_SIZE);
this.clubs = res.data.results;
this.loading = false;
},
getParagraphs(s: string) {
if (!s) {
return [];
}
return s
.split("\n")
.map((s) => s.trim())
.filter((s) => s !== "");
},
}));
});

View File

@@ -1,52 +1,39 @@
{% extends "core/base.jinja" %} {% if is_fragment %}
{% from "core/macros.jinja" import paginate_alpine %} {% extends "core/base_fragment.jinja" %}
{% block title -%} {# Don't display tabs and errors #}
{% trans %}Club list{% endtrans %} {% block tabs %}
{%- endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/club/club-list-index.ts") }}"></script>
{% endblock %} {% endblock %}
{% block errors %}
{% endblock %}
{% else %}
{% extends "core/base.jinja" %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}"> <link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %} {% endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% block title -%}
{% trans %}Club list{% endtrans %}
{%- endblock %}
{% endif %}
{% from "core/macros.jinja" import paginate_htmx %}
{% block content %} {% block content %}
<main x-data="clubList"> <main>
<h3>{% trans %}Filters{% endtrans %}</h3> <h3>{% trans %}Filters{% endtrans %}</h3>
<form id="club-list-filters"> <form
id="club-list-filters"
hx-get="{{ url("club:club_list") }}"
hx-target="#content"
hx-swap="outerHtml"
>
<div class="row gap-4x"> <div class="row gap-4x">
<fieldset> {{ form }}
<label for="club-name-input">{% trans %}Name{% endtrans %}</label>
<input
id="club-name-input"
type="text"
name="club-name"
x-model.debounce.500ms="clubName"
/>
</fieldset>
<fieldset class="grow">
<legend>{% trans %}Club state{% endtrans %}</legend>
<div class="row">
<input type="radio" id="filter-active-clubs" x-model="clubStatus" value="active">
<label for="filter-active-clubs">{% trans %}Active{% endtrans %}</label>
</div>
<div class="row">
<input type="radio" id="filter-inactive-clubs" x-model="clubStatus" value="inactive">
<label for="filter-inactive-clubs">{% trans %}Inactive{% endtrans %}</label>
</div>
<div class="row">
<input type="radio" id="filter-all-clubs" x-model="clubStatus" value="both">
<label for="filter-all-clubs">{% trans %}All clubs{% endtrans %}</label>
</div>
</fieldset>
</div> </div>
<input type="submit" class="btn btn-blue margin-bottom" value="{% trans %}Search{% endtrans %}">
</form> </form>
<h3>{% trans %}Club list{% endtrans %}</h3> <h3>{% trans %}Club list{% endtrans %}</h3>
{% if user.has_perm("club.add_club") %} {% if user.has_perm("club.add_club") %}
@@ -55,29 +42,31 @@
<i class="fa fa-plus"></i> {% trans %}New club{% endtrans %} <i class="fa fa-plus"></i> {% trans %}New club{% endtrans %}
</a> </a>
{% endif %} {% endif %}
<section class="aria-busy-grow" :aria-busy="loading" id="club-list"> <section class="aria-busy-grow" id="club-list">
<template x-for="club of clubs" :key="club.id"> {% for club in object_list %}
<div class="card"> <div class="card">
<a :href="club.url"> {% set club_url = club.get_absolute_url() %}
<template x-if="club.logo"> <a href="{{ club_url }}">
<img class="club-image" :src="club.logo" :alt="`logo ${club.name}`"> {% if club.logo %}
</template> <img class="club-image" src="{{ club.logo.url }}" alt="logo {{ club.name }}">
<template x-if="!club.logo"> {% else %}
<i class="fa-regular fa-image fa-4x club-image"></i> <i class="fa-regular fa-image fa-4x club-image"></i>
</template> {% endif %}
</a> </a>
<div class="content"> <div class="content">
<a :href="club.url"> <a href="{{ club_url }}">
<h4 x-text="`${club.name} ${club.is_active ? '' : '({% trans %}inactive{% endtrans %})'}`"></h4> <h4>
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
</h4>
</a> </a>
<template x-for="paragraph of getParagraphs(club.short_description)"> {{ club.short_description|markdown }}
<p x-text="paragraph"></p>
</template>
</div> </div>
</div> </div>
</template> {% endfor %}
</section> </section>
{{ paginate_alpine("currentPage", "nbPages") }} {% if is_paginated %}
{{ paginate_htmx(request, page_obj, paginator) }}
{% endif %}
</main> </main>
{% endblock %} {% endblock %}

View File

@@ -42,15 +42,21 @@ from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, TemplateView, View from django.views.generic import DetailView, ListView, View
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import (
CreateView,
DeleteView,
FormMixin,
UpdateView,
)
from club.forms import ( from club.forms import (
ClubAddMemberForm, ClubAddMemberForm,
ClubAdminEditForm, ClubAdminEditForm,
ClubEditForm, ClubEditForm,
ClubOldMemberForm, ClubOldMemberForm,
ClubSearchForm,
JoinClubForm, JoinClubForm,
MailingForm, MailingForm,
SellingsForm, SellingsForm,
@@ -66,7 +72,12 @@ from com.views import (
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev from core.models import Page, PageRev
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin from core.views.mixins import (
AllowFragment,
FragmentMixin,
FragmentRenderer,
TabedViewMixin,
)
from counter.models import Selling from counter.models import Selling
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -180,10 +191,41 @@ class ClubTabsMixin(TabedViewMixin):
return tab_list return tab_list
class ClubListView(TemplateView): class ClubListView(AllowFragment, FormMixin, ListView):
"""List the Clubs.""" """List the clubs of the AE, with a form to perform basic search.
Notes:
This view is fully public, because we want to advertise as much as possible
the cultural life of the AE.
In accordance with that matter, searching and listing the clubs is done
entirely server-side (no AlpineJS involved) ;
this is done this way in order to be sure the page is the most accessible
and SEO-friendly possible, even if it makes the UX slightly less smooth.
"""
template_name = "club/club_list.jinja" template_name = "club/club_list.jinja"
form_class = ClubSearchForm
queryset = Club.objects.order_by("name")
paginate_by = 1
def get_form_kwargs(self):
res = super().get_form_kwargs()
if self.request.GET:
res |= {"data": self.request.GET, "initial": self.request.GET}
return res
def get_queryset(self):
form: ClubSearchForm = self.get_form()
qs = self.queryset
if not form.is_bound:
return qs
if not form.is_valid():
return qs.none()
if name := form.cleaned_data.get("name"):
qs = qs.filter(name__icontains=name)
if (is_active := form.cleaned_data.get("club_status")) is not None:
qs = qs.filter(is_active=is_active)
return qs
class ClubView(ClubTabsMixin, DetailView): class ClubView(ClubTabsMixin, DetailView):