ajaxify the product admin page

This commit is contained in:
imperosol 2024-12-14 00:10:34 +01:00
parent 3fc260a12c
commit 39b36aa509
8 changed files with 188 additions and 67 deletions

View File

@ -22,10 +22,13 @@ type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
// TODO : If one day a test workflow is made for JS in this project // TODO : If one day a test workflow is made for JS in this project
// please test this function. A all cost. // please test this function. A all cost.
/**
* Load complete dataset from paginated routes.
*/
export const paginated = async <T>( export const paginated = async <T>(
endpoint: PaginatedEndpoint<T>, endpoint: PaginatedEndpoint<T>,
options?: PaginatedRequest, options?: PaginatedRequest,
) => { ): Promise<T[]> => {
const maxPerPage = 199; const maxPerPage = 199;
const queryParams = options ?? {}; const queryParams = options ?? {};
queryParams.query = queryParams.query ?? {}; queryParams.query = queryParams.query ?? {};

View File

@ -198,6 +198,9 @@ body {
margin: 20px auto 0; margin: 20px auto 0;
/*---------------------------------NAV---------------------------------*/ /*---------------------------------NAV---------------------------------*/
a.btn {
display: inline-block;
}
.btn { .btn {
font-size: 15px; font-size: 15px;
font-weight: normal; font-weight: normal;
@ -409,6 +412,11 @@ body {
} }
} }
.row {
display: flex;
flex-wrap: wrap;
}
/*---------------------------------NEWS--------------------------------*/ /*---------------------------------NEWS--------------------------------*/
#news { #news {
display: flex; display: flex;

View File

@ -0,0 +1,88 @@
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import {
type ProductSchema,
productSearchProductsDetailed,
type ProductSearchProductsDetailedData,
} from "#openapi";
type ProductType = string;
type GroupedProducts = Record<ProductType, ProductSchema[]>;
const defaultPageSize = 100;
const defaultPage = 1;
document.addEventListener("alpine:init", () => {
Alpine.data("productList", () => ({
loading: false,
csvLoading: false,
products: {} as GroupedProducts,
/** Total number of elements corresponding to the current query. */
nbPages: 0,
isArchived: null as boolean,
search: "",
pageSize: defaultPageSize,
page: defaultPage,
async init() {
const url = getCurrentUrlParams();
this.search = url.get("search") || "";
this.isArchived = url.get("isArchived") ?? false;
await this.load();
for (const param of ["search", "isArchived"]) {
this.$watch(param, () => {
this.page = defaultPage;
});
}
for (const param of ["search", "isArchived", "page"]) {
this.$watch(param, async (value: string) => {
updateQueryString(param, value, History.Replace);
this.nbPages = 0;
this.products = {};
await this.load();
});
}
},
/**
* Build the object containing the query parameters corresponding
* to the current filters
*/
getQueryParams(): ProductSearchProductsDetailedData {
const search = this.search.length > 0 ? this.search : null;
const archived = ["true", "false"].includes(this.isArchived)
? this.isArchived
: undefined;
return {
query: {
page: this.page,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: this.pageSize,
search: search,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_archived: archived,
},
};
},
/**
* Fetch the products corresponding to the current filters
*/
async load() {
this.loading = true;
const options = this.getQueryParams();
const resp = await productSearchProductsDetailed(options);
this.nbPages = Math.ceil(resp.data.count / defaultPageSize);
this.products = resp.data.results.reduce<GroupedProducts>((acc, curr) => {
const key = curr.product_type?.name ?? gettext("Uncategorized");
if (!(key in acc)) {
acc[key] = [];
}
acc[key].push(curr);
return acc;
}, {});
this.loading = false;
},
}));
});

View File

@ -1,26 +1,61 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "core/macros.jinja" import paginate_alpine %}
{% block title %} {% block title %}
{% trans %}Product list{% endtrans %} {% trans %}Product list{% endtrans %}
{% endblock %} {% endblock %}
{% block content %} {% block additional_js %}
{% if current_tab == "products" %} <script type="module" src="{{ static("bundled/counter/product-list-index.ts") }}"></script>
<p><a href="{{ url('counter:new_product') }}">{% trans %}New product{% endtrans %}</a></p>
{% endif %}
<h3>{% trans %}Product list{% endtrans %}</h3>
{%- for product_type, products in object_list -%}
<h4>{{ product_type or _("Uncategorized") }}</h4>
<ul>
{%- for product in products -%}
<li><a href="{{ url('counter:product_edit', product_id=product.id) }}">{{ product.name }} ({{ product.code }})</a></li>
{%- endfor -%}
</ul>
{%- else -%}
{% trans %}There is no products in this website.{% endtrans %}
{%- endfor -%}
{% endblock %} {% endblock %}
{% block content %}
<main x-data="productList">
<form id="search-form">
<h4>{% trans %}Filter products{% endtrans %}</h4>
<fieldset>
<label for="search-input">{% trans %}Product name{% endtrans %}</label>
<input
id="search-input"
type="text"
name="search"
x-model.debounce.500ms="search"
/>
</fieldset>
<fieldset>
<div class="row">
<input type="radio" id="filter-active-products" x-model="isArchived" value="false">
<label for="filter-active-products">{% trans %}Active products{% endtrans %}</label>
</div>
<div class="row">
<input type="radio" id="filter-inactive-products" x-model="isArchived" value="true">
<label for="filter-inactive-products">{% trans %}Archived products{% endtrans %}</label>
</div>
<div class="row">
<input type="radio" id="filter-all-products" x-model="isArchived" value="">
<label for="filter-all-products">{% trans %}All products{% endtrans %}</label>
</div>
</fieldset>
</form>
<h3 @click="console.log(totalCount, nbPages())">{% trans %}Product list{% endtrans %}</h3>
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
</a>
<template x-if="loading">
<section :aria-busy="loading"></section>
</template>
<template x-for="[category, cat_products] of Object.entries(products)" :key="category">
<section>
<h4 x-text="category"></h4>
<ul>
<template x-for="p in cat_products" :key="p.id">
<li><a :href="p.url" x-text="`${p.name} (${p.code})`"></a></li>
</template>
</ul>
</section>
</template>
{{ paginate_alpine("page", "nbPages") }}
</main>
{% endblock %}

View File

@ -16,8 +16,6 @@
from django.urls import path from django.urls import path
from counter.views.admin import ( from counter.views.admin import (
ActiveProductListView,
ArchivedProductListView,
CounterCreateView, CounterCreateView,
CounterDeleteView, CounterDeleteView,
CounterEditPropView, CounterEditPropView,
@ -27,6 +25,7 @@ from counter.views.admin import (
CounterStatView, CounterStatView,
ProductCreateView, ProductCreateView,
ProductEditView, ProductEditView,
ProductListView,
ProductTypeCreateView, ProductTypeCreateView,
ProductTypeEditView, ProductTypeEditView,
ProductTypeListView, ProductTypeListView,
@ -108,12 +107,7 @@ urlpatterns = [
CashSummaryEditView.as_view(), CashSummaryEditView.as_view(),
name="cash_summary_edit", name="cash_summary_edit",
), ),
path("admin/product/list/", ActiveProductListView.as_view(), name="product_list"), path("admin/product/", ProductListView.as_view(), name="product_list"),
path(
"admin/product/list_archived/",
ArchivedProductListView.as_view(),
name="product_list_archived",
),
path("admin/product/create/", ProductCreateView.as_view(), name="new_product"), path("admin/product/create/", ProductCreateView.as_view(), name="new_product"),
path( path(
"admin/product/<int:product_id>/", "admin/product/<int:product_id>/",

View File

@ -12,19 +12,16 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
import itertools
from datetime import timedelta from datetime import timedelta
from operator import itemgetter
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import F
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.utils import get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
@ -125,40 +122,9 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
current_tab = "products" current_tab = "products"
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
model = Product
queryset = Product.objects.values("id", "name", "code", "product_type__name")
template_name = "counter/product_list.jinja"
ordering = [
F("product_type__order").asc(nulls_last=True),
"product_type",
"name",
]
def get_context_data(self, **kwargs):
res = super().get_context_data(**kwargs)
res["object_list"] = itertools.groupby(
res["object_list"], key=itemgetter("product_type__name")
)
return res
class ArchivedProductListView(ProductListView):
"""A list view for the admins."""
current_tab = "archive"
def get_queryset(self):
return super().get_queryset().filter(archived=True)
class ActiveProductListView(ProductListView):
"""A list view for the admins."""
current_tab = "products" current_tab = "products"
template_name = "counter/product_list.jinja"
def get_queryset(self):
return super().get_queryset().filter(archived=False)
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):

View File

@ -93,11 +93,6 @@ class CounterAdminTabsMixin(TabedViewMixin):
"slug": "products", "slug": "products",
"name": _("Products"), "name": _("Products"),
}, },
{
"url": reverse_lazy("counter:product_list_archived"),
"slug": "archive",
"name": _("Archived products"),
},
{ {
"url": reverse_lazy("counter:product_type_list"), "url": reverse_lazy("counter:product_type_list"),
"slug": "product_types", "slug": "product_types",

View File

@ -122,6 +122,38 @@ msgstr "photos.%(extension)s"
msgid "captured.%s" msgid "captured.%s"
msgstr "capture.%s" msgstr "capture.%s"
#: counter/static/bundled/counter/product-list-index.ts:39
msgid "name"
msgstr "nom"
#: counter/static/bundled/counter/product-list-index.ts:42
msgid "product type"
msgstr "type de produit"
#: counter/static/bundled/counter/product-list-index.ts:44
msgid "limit age"
msgstr "limite d'âge"
#: counter/static/bundled/counter/product-list-index.ts:45
msgid "purchase price"
msgstr "prix d'achat"
#: counter/static/bundled/counter/product-list-index.ts:46
msgid "selling price"
msgstr "prix de vente"
#: counter/static/bundled/counter/product-list-index.ts:47
msgid "archived"
msgstr "archivé"
#: counter/static/bundled/counter/product-list-index.ts:114
msgid "Uncategorized"
msgstr "Sans catégorie"
#: counter/static/bundled/counter/product-list-index.ts:132
msgid "products.csv"
msgstr "produits.csv"
#: counter/static/bundled/counter/product-type-index.ts:36 #: counter/static/bundled/counter/product-type-index.ts:36
msgid "Products types reordered!" msgid "Products types reordered!"
msgstr "Types de produits réordonnés !" msgstr "Types de produits réordonnés !"