From 1696a2f5793f147099cb2784507bc0836efcb1bd Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 14 Dec 2024 00:06:18 +0100 Subject: [PATCH 01/11] Add NestedKeyOf Type --- core/static/bundled/utils/types.d.ts | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 core/static/bundled/utils/types.d.ts diff --git a/core/static/bundled/utils/types.d.ts b/core/static/bundled/utils/types.d.ts new file mode 100644 index 00000000..e9040c67 --- /dev/null +++ b/core/static/bundled/utils/types.d.ts @@ -0,0 +1,37 @@ +/** + * A key of an object, or of one of its descendants. + * + * Example : + * ```typescript + * interface Foo { + * foo_inner: number; + * } + * + * interface Bar { + * foo: Foo; + * } + * + * const foo = (key: NestedKeyOf) { + * console.log(key); + * } + * + * foo("foo.foo_inner"); // OK + * foo("foo.bar"); // FAIL + * ``` + */ +export type NestedKeyOf = { + [Key in keyof T & (string | number)]: NestedKeyOfHandleValue; +}[keyof T & (string | number)]; + +type NestedKeyOfInner = { + [Key in keyof T & (string | number)]: NestedKeyOfHandleValue< + T[Key], + `['${Key}']` | `.${Key}` + >; +}[keyof T & (string | number)]; + +type NestedKeyOfHandleValue = T extends unknown[] + ? Text + : T extends object + ? Text | `${Text}${NestedKeyOfInner}` + : Text; From 3fc260a12cbc166f68c0aa6763670588a87e9241 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 14 Dec 2024 00:08:11 +0100 Subject: [PATCH 02/11] add csv converter --- core/static/bundled/utils/csv.ts | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 core/static/bundled/utils/csv.ts diff --git a/core/static/bundled/utils/csv.ts b/core/static/bundled/utils/csv.ts new file mode 100644 index 00000000..b4420345 --- /dev/null +++ b/core/static/bundled/utils/csv.ts @@ -0,0 +1,43 @@ +import type { NestedKeyOf } from "#core:utils/types"; + +interface StringifyOptions { + /** The columns to include in the resulting CSV. */ + columns: readonly NestedKeyOf[]; + /** Content of the first row */ + titleRow?: readonly string[]; +} + +function getNested(obj: T, key: NestedKeyOf) { + const path: (keyof object)[] = key.split(".") as (keyof unknown)[]; + let res = obj[path.shift() as keyof T]; + for (const node of path) { + if (res === null) { + break; + } + res = res[node]; + } + return res; +} + +export const csv = { + stringify: (objs: T[], options?: StringifyOptions) => { + const columns = options.columns; + const content = objs + .map((obj) => { + return columns + .map((col) => { + return (getNested(obj, col) ?? "") + .toString() + .replace(/,/g, ",") + .replace(/\n/g, " "); + }) + .join(","); + }) + .join("\n"); + if (!options.titleRow) { + return content; + } + const firstRow = options.titleRow.map((s) => s.replace(/,/g, ",")).join(","); + return `${firstRow}\n${content}`; + }, +}; From 39b36aa509b07e40af9ada616df0a9c2ac5b090f Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 14 Dec 2024 00:10:34 +0100 Subject: [PATCH 03/11] ajaxify the product admin page --- core/static/bundled/utils/api.ts | 5 +- core/static/core/style.scss | 8 ++ .../bundled/counter/product-list-index.ts | 88 +++++++++++++++++++ counter/templates/counter/product_list.jinja | 67 ++++++++++---- counter/urls.py | 10 +-- counter/views/admin.py | 40 +-------- counter/views/mixins.py | 5 -- locale/fr/LC_MESSAGES/djangojs.po | 32 +++++++ 8 files changed, 188 insertions(+), 67 deletions(-) create mode 100644 counter/static/bundled/counter/product-list-index.ts diff --git a/core/static/bundled/utils/api.ts b/core/static/bundled/utils/api.ts index ac647cd7..5d72b3b6 100644 --- a/core/static/bundled/utils/api.ts +++ b/core/static/bundled/utils/api.ts @@ -22,10 +22,13 @@ type PaginatedEndpoint = ( // 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 ( endpoint: PaginatedEndpoint, options?: PaginatedRequest, -) => { +): Promise => { const maxPerPage = 199; const queryParams = options ?? {}; queryParams.query = queryParams.query ?? {}; diff --git a/core/static/core/style.scss b/core/static/core/style.scss index d7a396d1..88bb6cd2 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -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; diff --git a/counter/static/bundled/counter/product-list-index.ts b/counter/static/bundled/counter/product-list-index.ts new file mode 100644 index 00000000..80cd8627 --- /dev/null +++ b/counter/static/bundled/counter/product-list-index.ts @@ -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; + +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((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; + }, + })); +}); diff --git a/counter/templates/counter/product_list.jinja b/counter/templates/counter/product_list.jinja index 881d7800..92d955cc 100644 --- a/counter/templates/counter/product_list.jinja +++ b/counter/templates/counter/product_list.jinja @@ -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" %} -

{% trans %}New product{% endtrans %}

- {% endif %} -

{% trans %}Product list{% endtrans %}

- {%- for product_type, products in object_list -%} -

{{ product_type or _("Uncategorized") }}

- - {%- else -%} - {% trans %}There is no products in this website.{% endtrans %} - {%- endfor -%} +{% block additional_js %} + {% endblock %} +{% block content %} +
+
+

{% trans %}Filter products{% endtrans %}

+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+

{% trans %}Product list{% endtrans %}

+ + {% trans %}New product{% endtrans %} + + - + + {{ paginate_alpine("page", "nbPages") }} +
+{% endblock %} diff --git a/counter/urls.py b/counter/urls.py index 91564a8b..885b4b14 100644 --- a/counter/urls.py +++ b/counter/urls.py @@ -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//", diff --git a/counter/views/admin.py b/counter/views/admin.py index aa7e2c50..ab46581b 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -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): diff --git a/counter/views/mixins.py b/counter/views/mixins.py index 2e88f54c..b72d6694 100644 --- a/counter/views/mixins.py +++ b/counter/views/mixins.py @@ -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", diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 414bb603..43d19dbd 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -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 !" From 1a9556f8110d5de773012f3870f2da4aa0f6e0b0 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 14 Dec 2024 00:11:25 +0100 Subject: [PATCH 04/11] add a button to download products as csv --- .../bundled/counter/product-list-index.ts | 66 ++++++++++++++++++- counter/templates/counter/product_list.jinja | 16 ++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/counter/static/bundled/counter/product-list-index.ts b/counter/static/bundled/counter/product-list-index.ts index 80cd8627..f0c7ae4d 100644 --- a/counter/static/bundled/counter/product-list-index.ts +++ b/counter/static/bundled/counter/product-list-index.ts @@ -1,8 +1,12 @@ +import { paginated } from "#core:utils/api"; +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 ProductSchema, - productSearchProductsDetailed, type ProductSearchProductsDetailedData, + productSearchProductsDetailed, } from "#openapi"; type ProductType = string; @@ -11,6 +15,38 @@ type GroupedProducts = Record; const defaultPageSize = 100; const defaultPage = 1; +/** + * Keys of the properties to include in the CSV. + */ +const csvColumns = [ + "id", + "name", + "code", + "description", + "product_type.name", + "club.name", + "limit_age", + "purchase_price", + "selling_price", + "archived", +] as NestedKeyOf[]; + +/** + * Title of the csv columns. + */ +const csvColumnTitles = [ + "id", + gettext("name"), + "code", + "description", + gettext("product type"), + "club", + gettext("limit age"), + gettext("purchase price"), + gettext("selling price"), + gettext("archived"), +]; + document.addEventListener("alpine:init", () => { Alpine.data("productList", () => ({ loading: false, @@ -84,5 +120,33 @@ document.addEventListener("alpine:init", () => { }, {}); this.loading = false; }, + + /** + * Download products corresponding to the current filters as a CSV file. + * If the pagination has multiple pages, all pages are downloaded. + */ + async downloadCsv() { + this.csvLoading = true; + const fileHandle = await showSaveFilePicker({ + _preferPolyfill: false, + suggestedName: gettext("products.csv"), + types: [], + excludeAcceptAllOption: false, + }); + // if products to download are already in-memory, directly take them. + // If not, fetch them. + const products = + this.nbPages > 1 + ? await paginated(productSearchProductsDetailed, this.getQueryParams()) + : Object.values(this.products).flat(); + const content = csv.stringify(products, { + columns: csvColumns, + titleRow: csvColumnTitles, + }); + const file = await fileHandle.createWritable(); + await file.write(content); + await file.close(); + this.csvLoading = false; + }, })); }); diff --git a/counter/templates/counter/product_list.jinja b/counter/templates/counter/product_list.jinja index 92d955cc..5b2074e8 100644 --- a/counter/templates/counter/product_list.jinja +++ b/counter/templates/counter/product_list.jinja @@ -39,9 +39,19 @@

{% trans %}Product list{% endtrans %}

- - {% trans %}New product{% endtrans %} - +
+ + {% trans %}New product{% endtrans %} + + +
From aab093200b51217598640eabbe193f29fe07cd9d Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 14 Dec 2024 00:36:12 +0100 Subject: [PATCH 05/11] slightly improve style --- core/static/core/style.scss | 14 ++++++++ core/templates/core/macros.jinja | 2 +- counter/templates/counter/product_list.jinja | 35 ++++++++++---------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 88bb6cd2..e094e215 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -19,6 +19,13 @@ body { --loading-stroke: 5px; --loading-duration: 1s; position: relative; + + &.aria-busy-grow { + // Make sure the element take enough place to hold the loading wheel + min-height: calc((var(--loading-size)) * 1.5); + min-width: calc((var(--loading-size)) * 1.5); + overflow: hidden; + } } [aria-busy]:after { @@ -255,6 +262,13 @@ body { } } + /** + * A spacer below an element. Somewhat cleaner than putting
everywhere. + */ + .margin-bottom { + margin-bottom: 1.5rem; + } + /*--------------------------------CONTENT------------------------------*/ #quick_notif { width: 100%; diff --git a/core/templates/core/macros.jinja b/core/templates/core/macros.jinja index 6ab52cad..e624e87a 100644 --- a/core/templates/core/macros.jinja +++ b/core/templates/core/macros.jinja @@ -140,7 +140,7 @@ nb_page (str): call to a javascript function or variable returning the maximum number of pages to paginate #} -