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
// please test this function. A all cost.
/**
* Load complete dataset from paginated routes.
*/
export const paginated = async <T>(
endpoint: PaginatedEndpoint<T>,
options?: PaginatedRequest,
) => {
): Promise<T[]> => {
const maxPerPage = 199;
const queryParams = options ?? {};
queryParams.query = queryParams.query ?? {};

View File

@ -198,6 +198,9 @@ body {
margin: 20px auto 0;
/*---------------------------------NAV---------------------------------*/
a.btn {
display: inline-block;
}
.btn {
font-size: 15px;
font-weight: normal;
@ -409,6 +412,11 @@ body {
}
}
.row {
display: flex;
flex-wrap: wrap;
}
/*---------------------------------NEWS--------------------------------*/
#news {
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" %}
{% from "core/macros.jinja" import paginate_alpine %}
{% block title %}
{% trans %}Product list{% endtrans %}
{% endblock %}
{% block content %}
{% if current_tab == "products" %}
<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 -%}
{% block additional_js %}
<script type="module" src="{{ static("bundled/counter/product-list-index.ts") }}"></script>
{% 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 counter.views.admin import (
ActiveProductListView,
ArchivedProductListView,
CounterCreateView,
CounterDeleteView,
CounterEditPropView,
@ -27,6 +25,7 @@ from counter.views.admin import (
CounterStatView,
ProductCreateView,
ProductEditView,
ProductListView,
ProductTypeCreateView,
ProductTypeEditView,
ProductTypeListView,
@ -108,12 +107,7 @@ urlpatterns = [
CashSummaryEditView.as_view(),
name="cash_summary_edit",
),
path("admin/product/list/", ActiveProductListView.as_view(), name="product_list"),
path(
"admin/product/list_archived/",
ArchivedProductListView.as_view(),
name="product_list_archived",
),
path("admin/product/", ProductListView.as_view(), name="product_list"),
path("admin/product/create/", ProductCreateView.as_view(), name="new_product"),
path(
"admin/product/<int:product_id>/",

View File

@ -12,19 +12,16 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
import itertools
from datetime import timedelta
from operator import itemgetter
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db.models import F
from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
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 core.utils import get_semester_code, get_start_of_semester
@ -125,40 +122,9 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
current_tab = "products"
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
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."""
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
current_tab = "products"
def get_queryset(self):
return super().get_queryset().filter(archived=False)
template_name = "counter/product_list.jinja"
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):

View File

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

View File

@ -122,6 +122,38 @@ msgstr "photos.%(extension)s"
msgid "captured.%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
msgid "Products types reordered!"
msgstr "Types de produits réordonnés !"