Make products filterable by product type

This commit is contained in:
imperosol 2024-12-21 02:13:37 +01:00
parent 6953eaa9d0
commit accf1befce
7 changed files with 151 additions and 61 deletions

View File

@ -107,7 +107,7 @@ form {
}
}
label {
label, legend {
display: block;
margin-bottom: 8px;

View File

@ -429,6 +429,16 @@ body {
.row {
display: flex;
flex-wrap: wrap;
$col-gap: 1rem;
&.gap {
column-gap: var($col-gap);
}
@for $i from 2 through 5 {
&.gap-#{$i}x {
column-gap: $i * $col-gap;
}
}
}
/*---------------------------------NEWS--------------------------------*/

View File

@ -4,9 +4,11 @@ import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type CounterSchema,
type ProductTypeSchema,
type SimpleProductSchema,
counterSearchCounter,
productSearchProducts,
producttypeFetchAll,
} from "#openapi";
@registerComponent("product-ajax-select")
@ -34,6 +36,37 @@ export class ProductAjaxSelect extends AjaxSelect {
}
}
@registerComponent("product-type-ajax-select")
export class ProductTypeAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["name"];
private productTypes = null as ProductTypeSchema[];
protected async search(query: string): Promise<TomOption[]> {
// The production database has a grand total of 26 product types
// and the filter logic is really simple.
// Thus, it's appropriate to fetch all product types during first use,
// then to reuse the result again and again.
if (this.productTypes === null) {
this.productTypes = (await producttypeFetchAll()).data || null;
}
return this.productTypes.filter((t) =>
t.name.toLowerCase().includes(query.toLowerCase()),
);
}
protected renderOption(item: ProductTypeSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ProductTypeSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}
@registerComponent("counter-ajax-select")
export class CounterAjaxSelect extends AjaxSelect {
protected valueField = "id";

View File

@ -3,6 +3,7 @@ import { csv } from "#core:utils/csv";
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import type { NestedKeyOf } from "#core:utils/types";
import { showSaveFilePicker } from "native-file-system-adapter";
import type TomSelect from "tom-select";
import {
type ProductSchema,
type ProductSearchProductsDetailedData,
@ -58,6 +59,7 @@ document.addEventListener("alpine:init", () => {
productStatus: "" as "active" | "archived" | "both",
search: "",
productTypes: [] as string[],
pageSize: defaultPageSize,
page: defaultPage,
@ -65,17 +67,22 @@ document.addEventListener("alpine:init", () => {
const url = getCurrentUrlParams();
this.search = url.get("search") || "";
this.productStatus = url.get("productStatus") ?? "active";
const widget = this.$refs.productTypesInput.widget as TomSelect;
widget.on("change", (items: string[]) => {
this.productTypes = [...items];
});
await this.load();
for (const param of ["search", "productStatus"]) {
const searchParams = ["search", "productStatus", "productTypes"];
for (const param of searchParams) {
this.$watch(param, () => {
this.page = defaultPage;
});
}
for (const param of ["search", "productStatus", "page"]) {
for (const param of [...searchParams, "page"]) {
this.$watch(param, async (value: string) => {
updateQueryString(param, value, History.Replace);
this.nbPages = 0;
this.products = {};
await this.load();
});
}
@ -100,6 +107,8 @@ document.addEventListener("alpine:init", () => {
search: search,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_archived: isArchived,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
product_type: this.productTypes,
},
};
},

View File

@ -6,45 +6,60 @@
{% endblock %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/counter/components/ajax-select-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/counter/product-list-index.ts") }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
<link rel="stylesheet" href="{{ static("counter/css/admin.scss") }}">
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
{% endblock %}
{% block content %}
<main x-data="productList">
<h4 class="margin-bottom">{% trans %}Filter products{% endtrans %}</h4>
<form id="search-form" class="margin-bottom">
<h4 class="margin-bottom">{% trans %}Filter products{% endtrans %}</h4>
<div class="row gap-4x">
<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>
<legend>{% trans %}Product state{% endtrans %}</legend>
<div class="row">
<input type="radio" id="filter-active-products" x-model="productStatus" value="active">
<label for="filter-active-products">{% trans %}Active products{% endtrans %}</label>
</div>
<div class="row">
<input type="radio" id="filter-inactive-products" x-model="productStatus" value="archived">
<label for="filter-inactive-products">{% trans %}Archived products{% endtrans %}</label>
</div>
<div class="row">
<input type="radio" id="filter-all-products" x-model="productStatus" value="both">
<label for="filter-all-products">{% trans %}All products{% endtrans %}</label>
</div>
</fieldset>
</div>
<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="productStatus" value="active">
<label for="filter-active-products">{% trans %}Active products{% endtrans %}</label>
</div>
<div class="row">
<input type="radio" id="filter-inactive-products" x-model="productStatus" value="archived">
<label for="filter-inactive-products">{% trans %}Archived products{% endtrans %}</label>
</div>
<div class="row">
<input type="radio" id="filter-all-products" x-model="productStatus" value="both">
<label for="filter-all-products">{% trans %}All products{% endtrans %}</label>
</div>
<label for="type-search-input">{% trans %}Product type{% endtrans %}</label>
<product-type-ajax-select
id="type-search-input"
name="product-type"
x-ref="productTypesInput"
multiple
>
</product-type-ajax-select>
</fieldset>
</form>
<h3 @click="console.log(totalCount, nbPages())" class="margin-bottom">
{% trans %}Product list{% endtrans %}
</h3>
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
<div class="row margin-bottom">
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">

View File

@ -1,8 +1,12 @@
from pydantic import TypeAdapter
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
from counter.models import Counter, Product
from counter.schemas import SimpleProductSchema, SimplifiedCounterSchema
from counter.models import Counter, Product, ProductType
from counter.schemas import (
ProductTypeSchema,
SimpleProductSchema,
SimplifiedCounterSchema,
)
_js = ["bundled/counter/components/ajax-select-index.ts"]
@ -33,3 +37,17 @@ class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple):
model = Product
adapter = TypeAdapter(list[SimpleProductSchema])
js = _js
class AutoCompleteSelectProductType(AutoCompleteSelect):
component_name = "product-type-ajax-select"
model = ProductType
adapter = TypeAdapter(list[ProductTypeSchema])
js = _js
class AutoCompleteSelectMultipleProductType(AutoCompleteSelectMultiple):
component_name = "product-type-ajax-select"
model = ProductType
adapter = TypeAdapter(list[ProductTypeSchema])
js = _js

View File

@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-19 10:43+0100\n"
"POT-Creation-Date: 2024-12-21 02:15+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -765,7 +765,7 @@ msgstr "Opération liée : "
#: core/templates/core/create.jinja:12 core/templates/core/edit.jinja:7
#: core/templates/core/edit.jinja:15 core/templates/core/edit.jinja:20
#: core/templates/core/file_edit.jinja:8
#: core/templates/core/macros_pages.jinja:25
#: core/templates/core/macros_pages.jinja:26
#: core/templates/core/page_prop.jinja:11
#: core/templates/core/user_godfathers.jinja:61
#: core/templates/core/user_godfathers_tree.jinja:85
@ -971,7 +971,7 @@ msgstr "Comptoir"
msgid "Products"
msgstr "Produits"
#: club/forms.py:168 counter/templates/counter/product_list.jinja:37
#: club/forms.py:168 counter/templates/counter/product_list.jinja:43
msgid "Archived products"
msgstr "Produits archivés"
@ -1146,7 +1146,7 @@ msgid "There are no members in this club."
msgstr "Il n'y a pas de membres dans ce club."
#: club/templates/club/club_members.jinja:80
#: core/templates/core/file_detail.jinja:19 core/views/forms.py:305
#: core/templates/core/file_detail.jinja:19 core/views/forms.py:309
#: launderette/views.py:208 trombi/templates/trombi/detail.jinja:19
msgid "Add"
msgstr "Ajouter"
@ -1192,7 +1192,7 @@ msgid "Show"
msgstr "Montrer"
#: club/templates/club/club_sellings.jinja:38
#: counter/templates/counter/product_list.jinja:59
#: counter/templates/counter/product_list.jinja:74
msgid "Download as cvs"
msgstr "Télécharger en CSV"
@ -2038,8 +2038,8 @@ msgid ""
"The groups this user belongs to. A user will get all permissions granted to "
"each of their groups."
msgstr ""
"Les groupes auxquels cet utilisateur appartient. Un utilisateur aura toutes les "
"permissions de chacun de ses groupes."
"Les groupes auxquels cet utilisateur appartient. Un utilisateur aura toutes "
"les permissions de chacun de ses groupes."
#: core/models.py:277
msgid "profile"
@ -2764,11 +2764,11 @@ msgstr "Tout désélectionner"
msgid "You're seeing the history of page \"%(page_name)s\""
msgstr "Vous consultez l'historique de la page \"%(page_name)s\""
#: core/templates/core/macros_pages.jinja:8
#: core/templates/core/macros_pages.jinja:10
msgid "last"
msgstr "actuel"
#: core/templates/core/macros_pages.jinja:21
#: core/templates/core/macros_pages.jinja:22
msgid "Edit page"
msgstr "Éditer la page"
@ -3026,7 +3026,7 @@ msgstr "Facture eboutic"
msgid "Etickets"
msgstr "Etickets"
#: core/templates/core/user_account.jinja:69 core/views/user.py:633
#: core/templates/core/user_account.jinja:69 core/views/user.py:631
msgid "User has no account"
msgstr "L'utilisateur n'a pas de compte"
@ -3326,7 +3326,8 @@ msgstr "Outils utilisateurs"
msgid "Sith management"
msgstr "Gestion de Sith"
#: core/templates/core/user_tools.jinja:21 core/views/user.py:254
#: core/templates/core/user_tools.jinja:21 core/views/forms.py:295
#: core/views/user.py:254
msgid "Groups"
msgstr "Groupes"
@ -3518,32 +3519,32 @@ msgstr "Blouse : montrez aux autres à quoi ressemble votre blouse !"
msgid "Bad image format, only jpeg, png, webp and gif are accepted"
msgstr "Mauvais format d'image, seuls les jpeg, png, webp et gif sont acceptés"
#: core/views/forms.py:302
#: core/views/forms.py:306
msgid "Godfather / Godmother"
msgstr "Parrain / Marraine"
#: core/views/forms.py:303
#: core/views/forms.py:307
msgid "Godchild"
msgstr "Fillot / Fillote"
#: core/views/forms.py:308 counter/forms.py:78 trombi/views.py:151
#: core/views/forms.py:312 counter/forms.py:78 trombi/views.py:151
msgid "Select user"
msgstr "Choisir un utilisateur"
#: core/views/forms.py:322
#: core/views/forms.py:326
msgid "This user does not exist"
msgstr "Cet utilisateur n'existe pas"
#: core/views/forms.py:324
#: core/views/forms.py:328
msgid "You cannot be related to yourself"
msgstr "Vous ne pouvez pas être relié à vous-même"
#: core/views/forms.py:336
#: core/views/forms.py:340
#, python-format
msgid "%s is already your godfather"
msgstr "%s est déjà votre parrain/marraine"
#: core/views/forms.py:342
#: core/views/forms.py:346
#, python-format
msgid "%s is already your godchild"
msgstr "%s est déjà votre fillot/fillote"
@ -4152,31 +4153,35 @@ msgstr ""
"aucune conséquence autre que le retrait de l'argent de votre compte."
#: counter/templates/counter/product_list.jinja:5
#: counter/templates/counter/product_list.jinja:46
#: counter/templates/counter/product_list.jinja:62
msgid "Product list"
msgstr "Liste des produits"
#: counter/templates/counter/product_list.jinja:20
#: counter/templates/counter/product_list.jinja:22
msgid "Filter products"
msgstr "Filtrer les produits"
#: counter/templates/counter/product_list.jinja:22
#: counter/templates/counter/product_list.jinja:27
msgid "Product name"
msgstr "Nom du produit"
#: counter/templates/counter/product_list.jinja:33
#, fuzzy
#| msgid "Archived products"
#: counter/templates/counter/product_list.jinja:36
msgid "Product state"
msgstr "Etat du produit"
#: counter/templates/counter/product_list.jinja:39
msgid "Active products"
msgstr "Produits archivés"
msgstr "Produits actifs"
#: counter/templates/counter/product_list.jinja:41
#, fuzzy
#| msgid "products"
#: counter/templates/counter/product_list.jinja:47
msgid "All products"
msgstr "produits"
msgstr "Tous les produits"
#: counter/templates/counter/product_list.jinja:51
#: counter/templates/counter/product_list.jinja:52
msgid "Product type"
msgstr "Type de produit"
#: counter/templates/counter/product_list.jinja:66
msgid "New product"
msgstr "Nouveau produit"