[ADD] website_sale_lazy_loading: load the /shop listing on demand
The shop pages products: every page is a full reload that throws away the grid the visitor was reading. This appends the next page to the grid instead, on scroll or on a "Load more products" click, per website. The controller does not duplicate /shop. The new /shop/lazy_products calls shop() and renders the cards out of the qcontext it prepared, so search, categories, attributes, tags, price filter, sort order and pricelists are supported by construction -- and so are the values other modules add to the listing, the wishlist state among them. Out of range pages answer empty rather than the last page again, which portal.pager would otherwise clamp to and the frontend would append as duplicates. The product loop of website_sale.products is replaced by a call to a shared template, so the first page and the appended ones are the same markup: a ribbon, a price or a button another module adds to products_item shows up on every card, not only on the ones the initial render produced. Progressive enhancement throughout: the first page and the pager are still what the standard controller renders, and the pager is only hidden once the widget is running. Without JavaScript -- and for crawlers -- the shop is exactly what it is without this module. The page size is the shop layout's "Products per page", the value the pager already uses, so there is nothing to keep in sync. The frontend takes no decision about what to show: the server sends the mode, the URL of the listing on screen and each page of cards. It observes a block below the grid rather than listening to scroll, restarts the public widgets on the appended cards, and keeps a button as the fallback for a failed request or a browser without IntersectionObserver. Tests cover the block rendering per mode, the filters travelling in the AJAX URL, and the endpoint on a next page, the last page, an out of range page, a broken page number and an unknown category. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5766406b6f
commit
a9d6f52ca6
22 changed files with 1039 additions and 0 deletions
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
|
|
@ -183,6 +183,7 @@ addons-cm/
|
||||||
├── # --- Ventas y web ---
|
├── # --- Ventas y web ---
|
||||||
├── website_sale_aplicoop/ # Sistema eskaera (compras grupo)
|
├── website_sale_aplicoop/ # Sistema eskaera (compras grupo)
|
||||||
├── website_sale_disable_cart/ # Tienda solo catálogo: desactiva el carrito estándar
|
├── website_sale_disable_cart/ # Tienda solo catálogo: desactiva el carrito estándar
|
||||||
|
├── website_sale_lazy_loading/ # /shop bajo demanda: scroll infinito o botón "Cargar más"
|
||||||
├── portal_event_registration/ # Portal: ver registros de eventos + adjuntos
|
├── portal_event_registration/ # Portal: ver registros de eventos + adjuntos
|
||||||
├── # --- Membresías ---
|
├── # --- Membresías ---
|
||||||
├── membership_monthly_invoicing/ # Factura mensual de membresía por socio (cron)
|
├── membership_monthly_invoicing/ # Factura mensual de membresía por socio (cron)
|
||||||
|
|
@ -222,6 +223,7 @@ addons-cm/
|
||||||
|
|
||||||
- [website_sale_aplicoop](../website_sale_aplicoop/README.rst) - Sistema eskaera completo
|
- [website_sale_aplicoop](../website_sale_aplicoop/README.rst) - Sistema eskaera completo
|
||||||
- [website_sale_disable_cart](../website_sale_disable_cart/README.rst) - Tienda solo catálogo: oculta el carrito estándar y redirige `/shop/cart*`
|
- [website_sale_disable_cart](../website_sale_disable_cart/README.rst) - Tienda solo catálogo: oculta el carrito estándar y redirige `/shop/cart*`
|
||||||
|
- [website_sale_lazy_loading](../website_sale_lazy_loading/README.rst) - `/shop` bajo demanda: scroll infinito o botón "Cargar más" en vez del paginador
|
||||||
- [portal_event_registration](../portal_event_registration/README.rst) - Portal: registros de eventos + adjuntos al chatter
|
- [portal_event_registration](../portal_event_registration/README.rst) - Portal: registros de eventos + adjuntos al chatter
|
||||||
|
|
||||||
**Membresías**
|
**Membresías**
|
||||||
|
|
|
||||||
56
website_sale_lazy_loading/LICENSE.txt
Normal file
56
website_sale_lazy_loading/LICENSE.txt
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Website Sale - Aplicoop
|
||||||
|
Copyright 2025 Criptomart SL
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
FULL LICENSE TEXT
|
||||||
|
=================
|
||||||
|
|
||||||
|
For the complete AGPL-3 license text, see:
|
||||||
|
https://www.gnu.org/licenses/agpl-3.0.html
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
SUMMARY OF RIGHTS
|
||||||
|
=================
|
||||||
|
|
||||||
|
When you distribute a modified version under AGPL-3, you must:
|
||||||
|
|
||||||
|
1. Keep the same license (AGPL-3)
|
||||||
|
2. Provide a copy of the license with your distribution
|
||||||
|
3. State what changes you made
|
||||||
|
4. Include the original copyright notices
|
||||||
|
5. If distributed over a network, provide source code access
|
||||||
|
|
||||||
|
Detailed information: https://www.gnu.org/licenses/agpl-3.0-standalone.html
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
ATTRIBUTION
|
||||||
|
===========
|
||||||
|
|
||||||
|
This module was developed by: Criptomart SL
|
||||||
|
Website: https://criptomart.net
|
||||||
|
|
||||||
|
Original inspiration: Aplicoop project
|
||||||
|
https://sourceforge.net/projects/aplicoop/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This file is part of the Website Sale - Aplicoop module for Odoo.
|
||||||
115
website_sale_lazy_loading/README.rst
Normal file
115
website_sale_lazy_loading/README.rst
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
================================
|
||||||
|
Website Sale - Lazy Loading
|
||||||
|
================================
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/badge/license-AGPL--3-blue.svg
|
||||||
|
:target: https://www.gnu.org/licenses/agpl-3.0-standalone.html
|
||||||
|
:alt: License: AGPL-3
|
||||||
|
.. image:: https://img.shields.io/badge/Odoo-18.0-blue
|
||||||
|
:alt: Odoo: 18.0
|
||||||
|
|
||||||
|
Loads the products of the eCommerce shop (``/shop``) **on demand** instead of
|
||||||
|
paginating them: the visitor keeps the products already on screen and the next
|
||||||
|
page is appended to the grid, either when they scroll to the bottom of the
|
||||||
|
listing (infinite scroll) or when they click a *Load more products* button.
|
||||||
|
|
||||||
|
It is the same idea as the lazy loading of ``website_sale_aplicoop``
|
||||||
|
(``/eskaera``), rebuilt for the standard shop.
|
||||||
|
|
||||||
|
What the module does
|
||||||
|
====================
|
||||||
|
|
||||||
|
- Adds a per-website setting choosing how the listing loads its pages:
|
||||||
|
infinite scroll, a *Load more* button, or the standard pager.
|
||||||
|
- Adds ``/shop/lazy_products``, which renders one page of the listing as an
|
||||||
|
HTML fragment (the product cards only) plus whether a further page exists.
|
||||||
|
- Replaces the product loop of ``website_sale.products`` with a call to a
|
||||||
|
shared template, so the first page and the appended ones are rendered by the
|
||||||
|
very same markup.
|
||||||
|
- Hides the standard pager from the frontend once the widget takes over.
|
||||||
|
|
||||||
|
The page size is the **Products per page** of the shop layout, the same value
|
||||||
|
the standard pager uses, so nothing has to be configured twice.
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
============
|
||||||
|
|
||||||
|
- The ``/shop`` controller is not duplicated: ``/shop/lazy_products`` calls
|
||||||
|
``shop()`` and renders the grid out of the values it prepared. Everything
|
||||||
|
the standard listing supports — search, categories, attributes, tags, price
|
||||||
|
filter, sort order, pricelists — is therefore supported as is, and so are
|
||||||
|
the values other modules add to the shop (the wishlist state, for example).
|
||||||
|
- The product cards come from ``website_sale.products_item``, so ribbons,
|
||||||
|
prices, add-to-cart, wishlist or comparison buttons added by other modules
|
||||||
|
are rendered exactly as on the first page. Public widgets are restarted on
|
||||||
|
the appended cards through ``widgets_start_request``.
|
||||||
|
- Progressive enhancement: the first page and the pager are still rendered by
|
||||||
|
the standard controller. Without JavaScript, the pager stays visible and the
|
||||||
|
shop behaves exactly as it does without this module — which is also what
|
||||||
|
search engine crawlers get.
|
||||||
|
- The frontend takes no decision about *what* to show: the server sends the
|
||||||
|
mode, the URL of the current listing and each page of cards.
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
=============
|
||||||
|
|
||||||
|
Go to *Website > Configuration > Settings > Shop - Products > Shop Product
|
||||||
|
Loading* and pick how the listing loads its pages. The setting is per website:
|
||||||
|
|
||||||
|
- **Infinite scroll** (default): the next page is appended when the visitor
|
||||||
|
reaches the bottom of the listing. A *Load more products* button shows up as
|
||||||
|
a fallback if a request fails or if the browser has no
|
||||||
|
``IntersectionObserver``.
|
||||||
|
- **Load more button**: the next page is appended when the visitor clicks the
|
||||||
|
button. This is the friendlier option for keyboard and screen reader users,
|
||||||
|
and it keeps the footer reachable.
|
||||||
|
- **Pager (standard)**: turns the module off and leaves the standard
|
||||||
|
``website_sale`` pager alone.
|
||||||
|
|
||||||
|
The number of products per page is the *Products per page* of the shop layout
|
||||||
|
(open ``/shop`` as editor, *Edit*, then the *Products Page* options).
|
||||||
|
|
||||||
|
Usage
|
||||||
|
=====
|
||||||
|
|
||||||
|
There is nothing to do at runtime. Worth knowing:
|
||||||
|
|
||||||
|
- Product links keep the page they belong to (``?page=3``), so coming back
|
||||||
|
from a product page lands on the right part of the listing.
|
||||||
|
- Changing a filter, a category or the sort order is still a normal page load;
|
||||||
|
lazy loading restarts from the first page of the new listing.
|
||||||
|
- Reloading the page starts again from the first page: the appended pages are
|
||||||
|
not part of the URL.
|
||||||
|
- The endpoint answers with the same data ``/shop`` would render for that
|
||||||
|
page, under the rights of the visitor making the request.
|
||||||
|
|
||||||
|
Known limitations
|
||||||
|
=================
|
||||||
|
|
||||||
|
- The listing is only appended, never reduced: it grows until the visitor
|
||||||
|
leaves or reloads the page.
|
||||||
|
- Infinite scroll pushes the footer away as long as pages remain; use the
|
||||||
|
button mode if the footer matters on your site.
|
||||||
|
|
||||||
|
Technical
|
||||||
|
=========
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
controllers/website_sale.py /shop values + /shop/lazy_products endpoint
|
||||||
|
models/website.py shop_lazy_loading (per website)
|
||||||
|
views/..._templates.xml shared grid template + lazy loading block
|
||||||
|
static/src/js/... public widget appending the pages
|
||||||
|
|
||||||
|
Tests: ``docker-compose run --rm odoo odoo $ODOO_ADDONS -d odoo --test-enable
|
||||||
|
--stop-after-init -u website_sale_lazy_loading``
|
||||||
|
|
||||||
|
Credits
|
||||||
|
=======
|
||||||
|
|
||||||
|
**Authors:** Criptomart
|
||||||
|
|
||||||
|
The approach (a server rendered fragment appended to the grid, an observer
|
||||||
|
close to the bottom of the listing and a button as a fallback) comes from the
|
||||||
|
lazy loading of the Eskaera shop in ``website_sale_aplicoop``, reimplemented
|
||||||
|
here on top of the standard ``website_sale`` controller and templates.
|
||||||
4
website_sale_lazy_loading/__init__.py
Normal file
4
website_sale_lazy_loading/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
from . import controllers
|
||||||
|
from . import models
|
||||||
28
website_sale_lazy_loading/__manifest__.py
Normal file
28
website_sale_lazy_loading/__manifest__.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Copyright 2026 - Today Criptomart
|
||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
{ # noqa: B018
|
||||||
|
"name": "Website Sale - Lazy Loading",
|
||||||
|
"version": "18.0.1.0.0",
|
||||||
|
"category": "Website/Sale",
|
||||||
|
"summary": "Load the /shop product listing on demand: infinite scroll or a "
|
||||||
|
"'Load more' button instead of the pager",
|
||||||
|
"author": "Criptomart",
|
||||||
|
"maintainers": ["Criptomart"],
|
||||||
|
"website": "https://git.criptomart.net/criptomart/addons-cm",
|
||||||
|
"license": "AGPL-3",
|
||||||
|
"depends": [
|
||||||
|
"website_sale",
|
||||||
|
],
|
||||||
|
"data": [
|
||||||
|
"views/res_config_settings_views.xml",
|
||||||
|
"views/website_sale_lazy_loading_templates.xml",
|
||||||
|
],
|
||||||
|
"assets": {
|
||||||
|
"web.assets_frontend": [
|
||||||
|
"website_sale_lazy_loading/static/src/css/website_sale_lazy_loading.css",
|
||||||
|
"website_sale_lazy_loading/static/src/js/website_sale_lazy_loading.js",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"installable": True,
|
||||||
|
}
|
||||||
3
website_sale_lazy_loading/controllers/__init__.py
Normal file
3
website_sale_lazy_loading/controllers/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
from . import website_sale
|
||||||
166
website_sale_lazy_loading/controllers/website_sale.py
Normal file
166
website_sale_lazy_loading/controllers/website_sale.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# Copyright 2026 - Today Criptomart
|
||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from werkzeug.exceptions import NotFound
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
from odoo.addons.website_sale.controllers.main import WebsiteSale
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LAZY_LOADING_ROUTE = "/shop/lazy_products"
|
||||||
|
GRID_ITEMS_TEMPLATE = "website_sale_lazy_loading.products_grid_items"
|
||||||
|
|
||||||
|
|
||||||
|
class WebsiteSaleLazyLoading(WebsiteSale):
|
||||||
|
"""Serve the /shop listing page by page over AJAX.
|
||||||
|
|
||||||
|
The standard ``/shop`` rendering is untouched: it still answers with the
|
||||||
|
first page and with its pager, so visitors without JavaScript (and search
|
||||||
|
engine crawlers) keep a fully navigable listing. What this controller adds
|
||||||
|
is a second entry point returning the very same product cards as an HTML
|
||||||
|
fragment, which the frontend appends to the grid already on screen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Values for the /shop page
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_additional_extra_shop_values(self, values, **post):
|
||||||
|
"""Feed the lazy loading block of the ``website_sale.products`` page."""
|
||||||
|
res = super()._get_additional_extra_shop_values(values, **post)
|
||||||
|
res.update(self._prepare_lazy_loading_values(values))
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _prepare_lazy_loading_values(self, values):
|
||||||
|
"""Return what the template needs to hand over to the frontend.
|
||||||
|
|
||||||
|
Everything is precomputed here (including the ``data-*`` payload) so
|
||||||
|
the template only reads plain values and the JavaScript side only
|
||||||
|
appends what the server sends back.
|
||||||
|
"""
|
||||||
|
pager = values.get("pager") or {}
|
||||||
|
page = pager.get("page", {}).get("num", 1)
|
||||||
|
page_count = pager.get("page_count", 1)
|
||||||
|
mode = request.website.shop_lazy_loading or "off"
|
||||||
|
# Nothing to lazy load on the last page: the block would only add an
|
||||||
|
# observer and a button that can never fetch anything.
|
||||||
|
if mode == "off" or page >= page_count:
|
||||||
|
return {"lazy_loading_active": False}
|
||||||
|
return {
|
||||||
|
"lazy_loading_active": True,
|
||||||
|
"lazy_loading_mode": mode,
|
||||||
|
"lazy_loading_url": self._get_lazy_loading_url(values),
|
||||||
|
"lazy_loading_page": page,
|
||||||
|
"lazy_loading_page_count": page_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_lazy_loading_url(self, values):
|
||||||
|
"""Return the AJAX URL carrying the filters of the current listing.
|
||||||
|
|
||||||
|
The query string of the current request already holds the search
|
||||||
|
terms, the attribute values, the tags, the price range and the sort
|
||||||
|
order; only the category travels in the path, so it is added back as a
|
||||||
|
parameter. The page number is dropped: the frontend sets it.
|
||||||
|
"""
|
||||||
|
params = request.httprequest.args.to_dict(flat=False)
|
||||||
|
params.pop("page", None)
|
||||||
|
category = values.get("category")
|
||||||
|
if category:
|
||||||
|
params["category"] = [str(category.id)]
|
||||||
|
query = urlencode(params, doseq=True)
|
||||||
|
if not query:
|
||||||
|
return LAZY_LOADING_ROUTE
|
||||||
|
return "%s?%s" % (LAZY_LOADING_ROUTE, query)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# AJAX endpoint
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route(
|
||||||
|
[LAZY_LOADING_ROUTE],
|
||||||
|
type="http",
|
||||||
|
auth="public",
|
||||||
|
website=True,
|
||||||
|
methods=["GET"],
|
||||||
|
sitemap=False,
|
||||||
|
)
|
||||||
|
def shop_lazy_products(self, page=1, category=None, **post):
|
||||||
|
"""Render one page of the shop listing as an HTML fragment.
|
||||||
|
|
||||||
|
:return: JSON with the ``html`` of the product cards, the ``page`` it
|
||||||
|
belongs to and whether a further page (``has_next``) exists.
|
||||||
|
"""
|
||||||
|
page = self._get_lazy_loading_page(page)
|
||||||
|
if category is not None and not str(category).isdigit():
|
||||||
|
# The category travels as a bare id; anything else is a broken URL
|
||||||
|
# rather than a category the visitor could have picked.
|
||||||
|
category = None
|
||||||
|
try:
|
||||||
|
response = self.shop(page=page, category=category, **post)
|
||||||
|
except NotFound:
|
||||||
|
return request.make_json_response(
|
||||||
|
{"error": "not_found", "html": "", "has_next": False}, status=404
|
||||||
|
)
|
||||||
|
values = getattr(response, "qcontext", None) or {}
|
||||||
|
if not getattr(response, "is_qweb", False) or "pager" not in values:
|
||||||
|
# /shop answered something else than the listing, e.g. the login
|
||||||
|
# redirect of a website whose eCommerce is restricted.
|
||||||
|
_logger.info("[LAZY_LOADING] /shop did not render the product listing")
|
||||||
|
return request.make_json_response(
|
||||||
|
{"error": "unavailable", "html": "", "has_next": False}, status=403
|
||||||
|
)
|
||||||
|
pager = values["pager"]
|
||||||
|
page_count = pager["page_count"]
|
||||||
|
if pager["page"]["num"] != page:
|
||||||
|
# Out of range: the pager clamps to the last page, and sending it
|
||||||
|
# again would duplicate the cards already on screen.
|
||||||
|
return request.make_json_response(
|
||||||
|
{"html": "", "page": page, "page_count": page_count, "has_next": False}
|
||||||
|
)
|
||||||
|
values = dict(values, **self._prepare_lazy_grid_values(values))
|
||||||
|
html = request.env["ir.ui.view"]._render_template(GRID_ITEMS_TEMPLATE, values)
|
||||||
|
_logger.debug(
|
||||||
|
"[LAZY_LOADING] page %s/%s rendered with %s products",
|
||||||
|
page,
|
||||||
|
page_count,
|
||||||
|
len(values["products"]),
|
||||||
|
)
|
||||||
|
return request.make_json_response(
|
||||||
|
{
|
||||||
|
"html": html,
|
||||||
|
"page": page,
|
||||||
|
"page_count": page_count,
|
||||||
|
"has_next": page < page_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_lazy_loading_page(self, page):
|
||||||
|
"""Return ``page`` as a page number, defaulting to the first page."""
|
||||||
|
try:
|
||||||
|
page = int(page)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 1
|
||||||
|
return max(page, 1)
|
||||||
|
|
||||||
|
def _prepare_lazy_grid_values(self, values):
|
||||||
|
"""Complete the shop values with what the grid layout needs.
|
||||||
|
|
||||||
|
``website_sale.products`` computes these on the fly right before the
|
||||||
|
grid; the fragment is rendered on its own, so they are rebuilt here
|
||||||
|
from the same shop options.
|
||||||
|
"""
|
||||||
|
website = request.website
|
||||||
|
has_left_column = website.is_view_active(
|
||||||
|
"website_sale.products_categories"
|
||||||
|
) or website.is_view_active("website_sale.products_attributes")
|
||||||
|
return {
|
||||||
|
"grid_md_allow_custom_cols": has_left_column,
|
||||||
|
"grid_md_use_3col": not has_left_column and values.get("ppr") == 4,
|
||||||
|
"product_block_name": "Product",
|
||||||
|
}
|
||||||
4
website_sale_lazy_loading/models/__init__.py
Normal file
4
website_sale_lazy_loading/models/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
from . import website
|
||||||
|
from . import res_config_settings
|
||||||
16
website_sale_lazy_loading/models/res_config_settings.py
Normal file
16
website_sale_lazy_loading/models/res_config_settings.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Copyright 2026 - Today Criptomart
|
||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
from odoo import fields
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = "res.config.settings"
|
||||||
|
|
||||||
|
# Per website: a shop with a handful of products may keep the pager while
|
||||||
|
# a catalog with thousands of them loads on demand.
|
||||||
|
shop_lazy_loading = fields.Selection(
|
||||||
|
related="website_id.shop_lazy_loading",
|
||||||
|
readonly=False,
|
||||||
|
)
|
||||||
30
website_sale_lazy_loading/models/website.py
Normal file
30
website_sale_lazy_loading/models/website.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Copyright 2026 - Today Criptomart
|
||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
from odoo import fields
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class Website(models.Model):
|
||||||
|
_inherit = "website"
|
||||||
|
|
||||||
|
shop_lazy_loading = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
("off", "Pager (standard)"),
|
||||||
|
("button", "Load more button"),
|
||||||
|
("scroll", "Infinite scroll"),
|
||||||
|
],
|
||||||
|
string="Shop Product Loading",
|
||||||
|
default="scroll",
|
||||||
|
required=True,
|
||||||
|
help="How the /shop listing loads the pages after the first one.\n"
|
||||||
|
"- Pager (standard): the visitor clicks the page numbers, each page "
|
||||||
|
"is a full reload.\n"
|
||||||
|
"- Load more button: the next page is appended when the visitor "
|
||||||
|
"clicks the button.\n"
|
||||||
|
"- Infinite scroll: the next page is appended when the visitor "
|
||||||
|
"reaches the bottom of the listing.\n"
|
||||||
|
"The page size is the 'Products per page' of the shop layout "
|
||||||
|
"(website editor), so it is shared with the standard pager. Visitors "
|
||||||
|
"without JavaScript always get the standard pager.",
|
||||||
|
)
|
||||||
17
website_sale_lazy_loading/readme/CONFIGURE.rst
Normal file
17
website_sale_lazy_loading/readme/CONFIGURE.rst
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
Go to *Website > Configuration > Settings > Shop - Products > Shop Product
|
||||||
|
Loading* and pick how the listing loads its pages. The setting is **per
|
||||||
|
website**:
|
||||||
|
|
||||||
|
* **Infinite scroll** (default): the next page is appended when the visitor
|
||||||
|
reaches the bottom of the listing. A *Load more products* button shows up as
|
||||||
|
a fallback if a request fails or if the browser has no
|
||||||
|
``IntersectionObserver``.
|
||||||
|
* **Load more button**: the next page is appended when the visitor clicks the
|
||||||
|
button. This is the friendlier option for keyboard and screen reader users,
|
||||||
|
and it keeps the footer reachable.
|
||||||
|
* **Pager (standard)**: turns the module off and leaves the standard
|
||||||
|
``website_sale`` pager alone.
|
||||||
|
|
||||||
|
The number of products of each page is **not** configured here: it is the
|
||||||
|
*Products per page* of the shop layout (open ``/shop`` as editor, *Edit* and
|
||||||
|
use the *Products Page* options), the same value the standard pager uses.
|
||||||
3
website_sale_lazy_loading/readme/CONTRIBUTORS.rst
Normal file
3
website_sale_lazy_loading/readme/CONTRIBUTORS.rst
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
* `Criptomart <https://criptomart.net>`_:
|
||||||
|
|
||||||
|
* Development and maintenance
|
||||||
10
website_sale_lazy_loading/readme/CREDITS.rst
Normal file
10
website_sale_lazy_loading/readme/CREDITS.rst
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
**Authors:**
|
||||||
|
|
||||||
|
* Criptomart
|
||||||
|
|
||||||
|
**Other credits:**
|
||||||
|
|
||||||
|
The approach (a server rendered fragment appended to the grid, an observer
|
||||||
|
close to the bottom of the listing and a button as a fallback) comes from the
|
||||||
|
lazy loading of the Eskaera shop in ``website_sale_aplicoop``, reimplemented
|
||||||
|
here on top of the standard ``website_sale`` controller and templates.
|
||||||
20
website_sale_lazy_loading/readme/DESCRIPTION.rst
Normal file
20
website_sale_lazy_loading/readme/DESCRIPTION.rst
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
This module loads the products of the eCommerce shop (``/shop``) **on demand**
|
||||||
|
instead of paginating them: the visitor keeps the products already on screen
|
||||||
|
and the next page is appended to the grid, either when they scroll to the
|
||||||
|
bottom of the listing (infinite scroll) or when they click a *Load more
|
||||||
|
products* button.
|
||||||
|
|
||||||
|
It is the same idea as the lazy loading of ``website_sale_aplicoop``
|
||||||
|
(``/eskaera``), rebuilt for the standard shop:
|
||||||
|
|
||||||
|
* The page size is the **Products per page** of the shop layout, the very same
|
||||||
|
value the standard pager uses, so nothing has to be configured twice.
|
||||||
|
* The product cards come from the standard ``website_sale.products_item``
|
||||||
|
template, so ribbons, prices, wishlist or comparison buttons added by other
|
||||||
|
modules are rendered exactly as on the first page.
|
||||||
|
* Search, categories, attributes, tags, price filter and sort order are kept:
|
||||||
|
the appended pages are the pages of the listing currently on screen.
|
||||||
|
|
||||||
|
The first page and the pager are still rendered by the standard controller, so
|
||||||
|
visitors without JavaScript — and search engine crawlers — keep a listing they
|
||||||
|
can navigate page by page.
|
||||||
6
website_sale_lazy_loading/readme/INSTALL.rst
Normal file
6
website_sale_lazy_loading/readme/INSTALL.rst
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
Install it like any other addon::
|
||||||
|
|
||||||
|
docker-compose run --rm odoo odoo -d odoo -i website_sale_lazy_loading --stop-after-init
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
It only depends on ``website_sale``.
|
||||||
24
website_sale_lazy_loading/readme/USAGE.rst
Normal file
24
website_sale_lazy_loading/readme/USAGE.rst
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
There is nothing to do at runtime: once the mode is set, ``/shop`` loads its
|
||||||
|
pages on demand.
|
||||||
|
|
||||||
|
How it works
|
||||||
|
~~~~~~~~~~~~
|
||||||
|
|
||||||
|
``/shop`` is rendered by the standard controller, with its pager. When a next
|
||||||
|
page exists, this module adds a block below the grid carrying the loading mode
|
||||||
|
and the URL of the current listing (search, category, attributes, tags, price
|
||||||
|
range and sort order included). The frontend widget then hides the pager and
|
||||||
|
requests ``/shop/lazy_products?...&page=N``, which answers with the product
|
||||||
|
cards of that page as an HTML fragment plus whether a further page exists, and
|
||||||
|
appends them to the grid.
|
||||||
|
|
||||||
|
Consequences worth knowing:
|
||||||
|
|
||||||
|
* Product links keep the page they belong to (``?page=3``), so coming back from
|
||||||
|
a product page lands on the right part of the listing.
|
||||||
|
* Changing a filter, a category or the sort order is still a normal page load;
|
||||||
|
lazy loading restarts from the first page of the new listing.
|
||||||
|
* Reloading the page starts again from the first page: the appended pages are
|
||||||
|
not part of the URL.
|
||||||
|
* The endpoint answers with the same data ``/shop`` would render for that page,
|
||||||
|
under the rights of the visitor making the request.
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
/* Lazy loading block of the shop listing (/shop).
|
||||||
|
Sits between the product grid and the standard pager. */
|
||||||
|
|
||||||
|
.o_wsale_lazy_loading {
|
||||||
|
/* Keeps something for the scroll observer to intersect with once the
|
||||||
|
button and the spinner are both hidden. */
|
||||||
|
min-height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_wsale_lazy_loading_spinner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import publicWidget from "@web/legacy/js/public/public_widget";
|
||||||
|
import { get } from "@web/core/network/http_service";
|
||||||
|
|
||||||
|
// How close to the loading block the visitor has to scroll before the next
|
||||||
|
// page is requested, so the products are there by the time they arrive.
|
||||||
|
const SCROLL_ROOT_MARGIN = "400px";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends the next pages of the /shop listing to the grid already on screen.
|
||||||
|
*
|
||||||
|
* The block this widget is attached to is only rendered when a next page
|
||||||
|
* exists, and it carries everything the server decided: the mode, the URL with
|
||||||
|
* the active filters and the page numbers. Nothing here decides *what* to
|
||||||
|
* show, it only asks for the next page and inserts the HTML it gets back.
|
||||||
|
*/
|
||||||
|
publicWidget.registry.WebsiteSaleLazyLoading = publicWidget.Widget.extend({
|
||||||
|
selector: ".o_wsale_lazy_loading",
|
||||||
|
disabledInEditableMode: true,
|
||||||
|
events: {
|
||||||
|
"click .o_wsale_lazy_loading_btn": "_onClickLoadMore",
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
start() {
|
||||||
|
this.gridEl = document.querySelector("#o_wsale_products_grid");
|
||||||
|
this.buttonEl = this.el.querySelector(".o_wsale_lazy_loading_btn");
|
||||||
|
this.spinnerEl = this.el.querySelector(".o_wsale_lazy_loading_spinner");
|
||||||
|
this.doneEl = this.el.querySelector(".o_wsale_lazy_loading_done");
|
||||||
|
this.errorEl = this.el.querySelector(".o_wsale_lazy_loading_error");
|
||||||
|
this.page = parseInt(this.el.dataset.page, 10) || 1;
|
||||||
|
this.pageCount = parseInt(this.el.dataset.pageCount, 10) || 1;
|
||||||
|
this.isLoading = false;
|
||||||
|
this.focusOnLoad = false;
|
||||||
|
if (this.gridEl && this.el.dataset.url) {
|
||||||
|
// The standard pager is the fallback for visitors without
|
||||||
|
// JavaScript, so it only goes away once this widget takes over.
|
||||||
|
this.pagerEl = document.querySelector(".products_pager");
|
||||||
|
if (this.pagerEl) {
|
||||||
|
this.pagerEl.classList.add("d-none");
|
||||||
|
}
|
||||||
|
this._activate();
|
||||||
|
}
|
||||||
|
return this._super(...arguments);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this._disconnectObserver();
|
||||||
|
if (this.pagerEl) {
|
||||||
|
this.pagerEl.classList.remove("d-none");
|
||||||
|
}
|
||||||
|
this._super(...arguments);
|
||||||
|
},
|
||||||
|
|
||||||
|
//--------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
//--------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts watching for the moment the next page has to be requested.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_activate() {
|
||||||
|
if (this.el.dataset.mode === "scroll" && window.IntersectionObserver) {
|
||||||
|
this.observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
this._loadNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: SCROLL_ROOT_MARGIN }
|
||||||
|
);
|
||||||
|
this.observer.observe(this.el);
|
||||||
|
} else {
|
||||||
|
// Manual mode, and fallback for browsers without observers.
|
||||||
|
this._showButton();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_disconnectObserver() {
|
||||||
|
if (this.observer) {
|
||||||
|
this.observer.disconnect();
|
||||||
|
this.observer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests the next page and appends its products to the grid.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async _loadNextPage() {
|
||||||
|
if (this.isLoading || this.page >= this.pageCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isLoading = true;
|
||||||
|
this._setLoading(true);
|
||||||
|
const url = new URL(this.el.dataset.url, window.location.origin);
|
||||||
|
url.searchParams.set("page", this.page + 1);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await get(url.toString());
|
||||||
|
} catch {
|
||||||
|
this._onLoadFailed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isDestroyed()) {
|
||||||
|
// The page went into edit mode while the request was in flight.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
|
if (!result || result.error) {
|
||||||
|
this._onLoadFailed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.page = result.page || this.page + 1;
|
||||||
|
this.pageCount = result.page_count || this.pageCount;
|
||||||
|
const newEls = result.html ? this._insertProducts(result.html) : [];
|
||||||
|
this._setLoading(false);
|
||||||
|
this.doneEl.classList.remove("d-none");
|
||||||
|
if (!result.has_next) {
|
||||||
|
this._onLastPageLoaded(newEls);
|
||||||
|
} else if (this.observer) {
|
||||||
|
// Observing again triggers a new callback when the block is still
|
||||||
|
// in view once the products are in, which happens on viewports
|
||||||
|
// taller than the page that was just loaded.
|
||||||
|
this.observer.unobserve(this.el);
|
||||||
|
this.observer.observe(this.el);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends the product cards and lets the public widgets bind to them.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param {string} html product cards rendered by the server
|
||||||
|
* @returns {HTMLElement[]} the cards that were added
|
||||||
|
*/
|
||||||
|
_insertProducts(html) {
|
||||||
|
const previousCount = this.gridEl.children.length;
|
||||||
|
this.gridEl.insertAdjacentHTML("beforeend", html);
|
||||||
|
const newEls = [...this.gridEl.children].slice(previousCount);
|
||||||
|
if (newEls.length) {
|
||||||
|
this.trigger_up("widgets_start_request", { $target: $(newEls) });
|
||||||
|
}
|
||||||
|
return newEls;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing left to load: stop watching and give the keyboard a landing
|
||||||
|
* spot, as the button the visitor just used is about to disappear.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param {HTMLElement[]} newEls
|
||||||
|
*/
|
||||||
|
_onLastPageLoaded(newEls) {
|
||||||
|
this._disconnectObserver();
|
||||||
|
this._hideButton();
|
||||||
|
if (this.focusOnLoad && newEls.length) {
|
||||||
|
const linkEl = newEls[0].querySelector("a");
|
||||||
|
if (linkEl) {
|
||||||
|
linkEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.focusOnLoad = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaves the visitor in control after a failed request: no more automatic
|
||||||
|
* requests, and a button to try again.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_onLoadFailed() {
|
||||||
|
this.isLoading = false;
|
||||||
|
this._disconnectObserver();
|
||||||
|
this._setLoading(false);
|
||||||
|
this.errorEl.classList.remove("d-none");
|
||||||
|
this._showButton();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @param {boolean} isLoading
|
||||||
|
*/
|
||||||
|
_setLoading(isLoading) {
|
||||||
|
this.spinnerEl.classList.toggle("d-none", !isLoading);
|
||||||
|
this.buttonEl.disabled = isLoading;
|
||||||
|
if (isLoading) {
|
||||||
|
this.errorEl.classList.add("d-none");
|
||||||
|
// Taking the message out while loading makes the live region
|
||||||
|
// announce every load, not only the first one.
|
||||||
|
this.doneEl.classList.add("d-none");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_showButton() {
|
||||||
|
this.buttonEl.classList.remove("d-none");
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_hideButton() {
|
||||||
|
this.buttonEl.classList.add("d-none");
|
||||||
|
},
|
||||||
|
|
||||||
|
//--------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
//--------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @param {Event} ev
|
||||||
|
*/
|
||||||
|
_onClickLoadMore(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.focusOnLoad = true;
|
||||||
|
this._loadNextPage();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default publicWidget.registry.WebsiteSaleLazyLoading;
|
||||||
3
website_sale_lazy_loading/tests/__init__.py
Normal file
3
website_sale_lazy_loading/tests/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
from . import test_lazy_loading
|
||||||
144
website_sale_lazy_loading/tests/test_lazy_loading.py
Normal file
144
website_sale_lazy_loading/tests/test_lazy_loading.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
# Copyright 2026 - Today Criptomart
|
||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import HttpCase
|
||||||
|
|
||||||
|
PER_PAGE = 3
|
||||||
|
PRODUCT_COUNT = 7
|
||||||
|
PAGE_COUNT = 3 # ceil(7 / 3)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged("post_install", "-at_install")
|
||||||
|
class TestLazyLoading(HttpCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.website = cls.env["website"].browse(1)
|
||||||
|
cls.website.shop_ppg = PER_PAGE
|
||||||
|
# The listing has to be predictable: only the products created here may
|
||||||
|
# show up in the shop.
|
||||||
|
cls.env["product.template"].search([("is_published", "=", True)]).write(
|
||||||
|
{"is_published": False}
|
||||||
|
)
|
||||||
|
cls.category = cls.env["product.public.category"].create({"name": "Lazy Cat"})
|
||||||
|
cls.other_category = cls.env["product.public.category"].create(
|
||||||
|
{"name": "Other Cat"}
|
||||||
|
)
|
||||||
|
cls.products = cls.env["product.template"].create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Lazy Product %02d" % index,
|
||||||
|
"list_price": 10.0 + index,
|
||||||
|
"is_published": True,
|
||||||
|
"public_categ_ids": [(6, 0, cls.category.ids)],
|
||||||
|
}
|
||||||
|
for index in range(PRODUCT_COUNT)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cls.other_product = cls.env["product.template"].create(
|
||||||
|
{
|
||||||
|
"name": "Other Product",
|
||||||
|
"list_price": 5.0,
|
||||||
|
"is_published": True,
|
||||||
|
"public_categ_ids": [(6, 0, cls.other_category.ids)],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_lazy_page(self, page, **params):
|
||||||
|
"""Call the AJAX endpoint and return its decoded payload."""
|
||||||
|
query = "&".join("%s=%s" % (key, value) for key, value in params.items())
|
||||||
|
url = "/shop/lazy_products?page=%s" % page
|
||||||
|
if query:
|
||||||
|
url = "%s&%s" % (url, query)
|
||||||
|
response = self.url_open(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /shop rendering
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_shop_renders_lazy_loading_block(self):
|
||||||
|
"""The listing carries the block driving the lazy loading."""
|
||||||
|
self.website.shop_lazy_loading = "scroll"
|
||||||
|
body = self.url_open("/shop").text
|
||||||
|
self.assertIn("o_wsale_lazy_loading", body)
|
||||||
|
self.assertIn('data-mode="scroll"', body)
|
||||||
|
self.assertIn('data-page-count="%s"' % PAGE_COUNT, body)
|
||||||
|
# The pager is still served: it is the fallback without JavaScript.
|
||||||
|
self.assertIn("products_pager", body)
|
||||||
|
|
||||||
|
def test_shop_without_lazy_loading(self):
|
||||||
|
"""The standard pager is left alone when the mode is off."""
|
||||||
|
self.website.shop_lazy_loading = "off"
|
||||||
|
body = self.url_open("/shop").text
|
||||||
|
self.assertNotIn("o_wsale_lazy_loading", body)
|
||||||
|
self.assertIn("products_pager", body)
|
||||||
|
|
||||||
|
def test_shop_last_page_has_no_block(self):
|
||||||
|
"""Nothing can be lazy loaded from the last page."""
|
||||||
|
self.website.shop_lazy_loading = "scroll"
|
||||||
|
body = self.url_open("/shop/page/%s" % PAGE_COUNT).text
|
||||||
|
self.assertNotIn("o_wsale_lazy_loading", body)
|
||||||
|
|
||||||
|
def test_lazy_loading_url_keeps_the_filters(self):
|
||||||
|
"""The AJAX URL carries the filters of the listing on screen."""
|
||||||
|
self.website.shop_lazy_loading = "scroll"
|
||||||
|
body = self.url_open("/shop?search=Lazy&order=name+desc").text
|
||||||
|
self.assertIn("/shop/lazy_products?", body)
|
||||||
|
self.assertIn("search=Lazy", body)
|
||||||
|
self.assertIn("order=name+desc", body)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /shop/lazy_products
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_lazy_page_returns_the_next_products(self):
|
||||||
|
"""A page holds the products of that page, and only those."""
|
||||||
|
result = self._get_lazy_page(2)
|
||||||
|
self.assertEqual(result["page"], 2)
|
||||||
|
self.assertEqual(result["page_count"], PAGE_COUNT)
|
||||||
|
self.assertTrue(result["has_next"])
|
||||||
|
self.assertIn("oe_product", result["html"])
|
||||||
|
for product in self.products[PER_PAGE : PER_PAGE * 2]:
|
||||||
|
self.assertIn(product.name, result["html"])
|
||||||
|
for product in self.products[:PER_PAGE]:
|
||||||
|
self.assertNotIn(product.name, result["html"])
|
||||||
|
|
||||||
|
def test_lazy_page_last_page(self):
|
||||||
|
"""The last page reports that there is nothing left to load."""
|
||||||
|
result = self._get_lazy_page(PAGE_COUNT)
|
||||||
|
self.assertFalse(result["has_next"])
|
||||||
|
self.assertIn(self.products[-1].name, result["html"])
|
||||||
|
|
||||||
|
def test_lazy_page_out_of_range(self):
|
||||||
|
"""An out of range page answers empty instead of repeating the last."""
|
||||||
|
result = self._get_lazy_page(PAGE_COUNT + 5)
|
||||||
|
self.assertEqual(result["html"], "")
|
||||||
|
self.assertFalse(result["has_next"])
|
||||||
|
|
||||||
|
def test_lazy_page_invalid_page(self):
|
||||||
|
"""A broken page number falls back to the first page."""
|
||||||
|
result = self._get_lazy_page("not-a-page")
|
||||||
|
self.assertEqual(result["page"], 1)
|
||||||
|
self.assertIn(self.products[0].name, result["html"])
|
||||||
|
|
||||||
|
def test_lazy_page_keeps_the_category(self):
|
||||||
|
"""Filtering by category is kept across the appended pages."""
|
||||||
|
result = self._get_lazy_page(2, category=self.category.id)
|
||||||
|
self.assertIn(self.products[PER_PAGE].name, result["html"])
|
||||||
|
self.assertNotIn(self.other_product.name, result["html"])
|
||||||
|
|
||||||
|
def test_lazy_page_ignores_a_broken_category(self):
|
||||||
|
"""A category that is not an id is dropped instead of crashing."""
|
||||||
|
result = self._get_lazy_page(1, category="pwned")
|
||||||
|
self.assertEqual(result["page"], 1)
|
||||||
|
self.assertIn(self.products[0].name, result["html"])
|
||||||
|
|
||||||
|
def test_lazy_page_unknown_category(self):
|
||||||
|
"""An unknown category answers a clean 404, not a traceback."""
|
||||||
|
unknown_id = self.other_category.id + 1000
|
||||||
|
response = self.url_open("/shop/lazy_products?page=1&category=%s" % unknown_id)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertEqual(response.json()["error"], "not_found")
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.inherit.lazy.loading</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="website_sale.res_config_settings_view_form" />
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<block id="sale_product_catalog_settings" position="inside">
|
||||||
|
<setting id="shop_lazy_loading_setting">
|
||||||
|
<label for="shop_lazy_loading" />
|
||||||
|
<span
|
||||||
|
class="fa fa-lg fa-globe"
|
||||||
|
title="Values set here are website-specific."
|
||||||
|
groups="website.group_multi_website"
|
||||||
|
/>
|
||||||
|
<div class="text-muted">
|
||||||
|
How the shop listing loads the pages after the first one
|
||||||
|
</div>
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="row mt16">
|
||||||
|
<field
|
||||||
|
name="shop_lazy_loading"
|
||||||
|
class="o_light_label"
|
||||||
|
widget="radio"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The product cards of the grid, extracted from `website_sale.products`
|
||||||
|
so that the first page (rendered by /shop) and the pages appended over
|
||||||
|
AJAX (rendered by /shop/lazy_products) always share the same markup.
|
||||||
|
Everything it reads comes from the standard shop values.
|
||||||
|
-->
|
||||||
|
<template id="products_grid_items" name="Shop Products Grid Items">
|
||||||
|
<t t-foreach="bins" t-as="tr_product">
|
||||||
|
<t t-foreach="tr_product" t-as="td_product">
|
||||||
|
<t t-if="td_product">
|
||||||
|
<t t-set="col_height" t-value="td_product['y']" />
|
||||||
|
<t t-set="col_width" t-value="12 // ppr * td_product['x']" />
|
||||||
|
<t t-set="col_class_lg" t-value="'g-col-lg-' + str(col_width)" />
|
||||||
|
<t
|
||||||
|
t-set="col_class_md"
|
||||||
|
t-value="grid_md_allow_custom_cols and ('g-col-md-' + str(col_width)) or grid_md_use_3col and 'g-col-md-4' or 'g-col-md-6'"
|
||||||
|
/>
|
||||||
|
<t
|
||||||
|
t-set="col_is_stretched"
|
||||||
|
t-value="(td_product['x'] >= td_product['y'] * 2)"
|
||||||
|
/>
|
||||||
|
<t
|
||||||
|
t-set="col_is_custom_portrait"
|
||||||
|
t-value="not col_is_stretched and (col_height > td_product['x'])"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
t-attf-class="oe_product {{col_is_custom_portrait and 'oe_product_custom_portrait'}} g-col-6 {{col_class_md}} {{col_class_lg}} {{col_is_stretched and 'oe_product_size_stretch'}}"
|
||||||
|
t-attf-style="--o-wsale-products-grid-product-col-height: {{col_height}};"
|
||||||
|
t-att-data-ribbon-id="td_product['ribbon'].id"
|
||||||
|
t-att-data-colspan="td_product['x'] != 1 and td_product['x']"
|
||||||
|
t-att-data-rowspan="td_product['y'] != 1 and td_product['y']"
|
||||||
|
t-att-data-name="product_block_name"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
t-attf-class="o_wsale_product_grid_wrapper position-relative h-100 o_wsale_product_grid_wrapper_#{td_product['x']}_#{td_product['y']}"
|
||||||
|
>
|
||||||
|
<t t-call="website_sale.products_item">
|
||||||
|
<t t-set="product" t-value="td_product['product']" />
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Shop listing: render the cards through the shared template and add the
|
||||||
|
block driving the lazy loading. The standard pager is left in place and
|
||||||
|
only hidden by the frontend, so a visitor without JavaScript keeps it.
|
||||||
|
-->
|
||||||
|
<template
|
||||||
|
id="products_lazy_loading"
|
||||||
|
inherit_id="website_sale.products"
|
||||||
|
name="Shop Lazy Loading"
|
||||||
|
>
|
||||||
|
<xpath
|
||||||
|
expr="//section[@id='o_wsale_products_grid']/t[@t-foreach='bins']"
|
||||||
|
position="replace"
|
||||||
|
>
|
||||||
|
<t t-call="website_sale_lazy_loading.products_grid_items" />
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//div[hasclass('products_pager')]" position="before">
|
||||||
|
<div
|
||||||
|
t-if="lazy_loading_active"
|
||||||
|
class="o_wsale_lazy_loading text-center pt-4"
|
||||||
|
t-att-data-mode="lazy_loading_mode"
|
||||||
|
t-att-data-url="lazy_loading_url"
|
||||||
|
t-att-data-page="lazy_loading_page"
|
||||||
|
t-att-data-page-count="lazy_loading_page_count"
|
||||||
|
>
|
||||||
|
<div class="o_wsale_lazy_loading_status" role="status" aria-live="polite">
|
||||||
|
<div class="o_wsale_lazy_loading_spinner d-none text-muted">
|
||||||
|
<span
|
||||||
|
class="spinner-border spinner-border-sm me-2"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>Loading more products…</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_wsale_lazy_loading_done visually-hidden d-none">
|
||||||
|
More products loaded.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="o_wsale_lazy_loading_btn btn btn-primary d-none"
|
||||||
|
>
|
||||||
|
Load more products
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="o_wsale_lazy_loading_error alert alert-warning d-none mt-3 mb-0"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
The next products could not be loaded. Please try again.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue