[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
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue