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"
)
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" %}
{% from "core/macros.jinja" import paginate_alpine %}
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% block title -%}
{% trans %}Club list{% endtrans %}
{%- endblock %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% else %}
{% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% block title -%}
{% trans %}Club list{% endtrans %}
{%- endblock %}
{% endif %}
{% 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 %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %}
{% from "core/macros.jinja" import paginate_htmx %}
{% block content %}
<main x-data="clubList">
<main>
<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">
<fieldset>
<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>
{{ form }}
</div>
<input type="submit" class="btn btn-blue margin-bottom" value="{% trans %}Search{% endtrans %}">
</form>
<h3>{% trans %}Club list{% endtrans %}</h3>
{% if user.has_perm("club.add_club") %}
@@ -55,29 +42,31 @@
<i class="fa fa-plus"></i> {% trans %}New club{% endtrans %}
</a>
{% endif %}
<section class="aria-busy-grow" :aria-busy="loading" id="club-list">
<template x-for="club of clubs" :key="club.id">
<section class="aria-busy-grow" id="club-list">
{% for club in object_list %}
<div class="card">
<a :href="club.url">
<template x-if="club.logo">
<img class="club-image" :src="club.logo" :alt="`logo ${club.name}`">
</template>
<template x-if="!club.logo">
{% set club_url = club.get_absolute_url() %}
<a href="{{ club_url }}">
{% if club.logo %}
<img class="club-image" src="{{ club.logo.url }}" alt="logo {{ club.name }}">
{% else %}
<i class="fa-regular fa-image fa-4x club-image"></i>
</template>
{% endif %}
</a>
<div class="content">
<a :href="club.url">
<h4 x-text="`${club.name} ${club.is_active ? '' : '({% trans %}inactive{% endtrans %})'}`"></h4>
<a href="{{ club_url }}">
<h4>
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
</h4>
</a>
<template x-for="paragraph of getParagraphs(club.short_description)">
<p x-text="paragraph"></p>
</template>
{{ club.short_description|markdown }}
</div>
</div>
</template>
{% endfor %}
</section>
{{ paginate_alpine("currentPage", "nbPages") }}
{% if is_paginated %}
{{ paginate_htmx(request, page_obj, paginator) }}
{% endif %}
</main>
{% endblock %}

View File

@@ -42,15 +42,21 @@ from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext
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.edit import CreateView, DeleteView, UpdateView
from django.views.generic.edit import (
CreateView,
DeleteView,
FormMixin,
UpdateView,
)
from club.forms import (
ClubAddMemberForm,
ClubAdminEditForm,
ClubEditForm,
ClubOldMemberForm,
ClubSearchForm,
JoinClubForm,
MailingForm,
SellingsForm,
@@ -66,7 +72,12 @@ from com.views import (
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev
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
if TYPE_CHECKING:
@@ -180,10 +191,41 @@ class ClubTabsMixin(TabedViewMixin):
return tab_list
class ClubListView(TemplateView):
"""List the Clubs."""
class ClubListView(AllowFragment, FormMixin, ListView):
"""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"
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):