perf: implement aggressive POS UI optimizations including product caching, lazy loading throttling, and hardware-accelerated rendering.
This commit is contained in:
parent
af4e446a7e
commit
a389ad963e
@ -48,6 +48,9 @@ Features
|
|||||||
# ProductScreen portrait tab state + layout
|
# ProductScreen portrait tab state + layout
|
||||||
'pos_ui_optimization/static/src/app/screens/product_screen/portrait_mode_patch.js',
|
'pos_ui_optimization/static/src/app/screens/product_screen/portrait_mode_patch.js',
|
||||||
'pos_ui_optimization/static/src/app/screens/product_screen/portrait_screen.xml',
|
'pos_ui_optimization/static/src/app/screens/product_screen/portrait_screen.xml',
|
||||||
|
# ProductCard and Orderline optimization patches
|
||||||
|
'pos_ui_optimization/static/src/app/components/product_card/product_card_patch.xml',
|
||||||
|
'pos_ui_optimization/static/src/app/components/orderline/orderline_patch.xml',
|
||||||
# Note splitting and defensive parsing patches
|
# Note splitting and defensive parsing patches
|
||||||
'pos_ui_optimization/static/src/app/screens/product_screen/notes_patch.js',
|
'pos_ui_optimization/static/src/app/screens/product_screen/notes_patch.js',
|
||||||
# Portrait CSS (must load after all XML patches)
|
# Portrait CSS (must load after all XML patches)
|
||||||
|
|||||||
@ -10,9 +10,15 @@ class PosConfig(models.Model):
|
|||||||
help="Hide the Cash In/Out button in the POS Navbar menu"
|
help="Hide the Cash In/Out button in the POS Navbar menu"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
hide_cart_images = fields.Boolean(
|
||||||
|
string="Hide Product Images in Cart",
|
||||||
|
default=False,
|
||||||
|
help="Hide product images in the order/cart lines inside POS"
|
||||||
|
)
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _load_pos_data_fields(self, config):
|
def _load_pos_data_fields(self, config):
|
||||||
fields_list = super()._load_pos_data_fields(config)
|
fields_list = super()._load_pos_data_fields(config)
|
||||||
if fields_list:
|
if fields_list:
|
||||||
fields_list.append('hide_cash_in_out_button')
|
fields_list.extend(['hide_cash_in_out_button', 'hide_cart_images'])
|
||||||
return fields_list
|
return fields_list
|
||||||
|
|||||||
@ -9,3 +9,9 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
related='pos_config_id.hide_cash_in_out_button',
|
related='pos_config_id.hide_cash_in_out_button',
|
||||||
readonly=False
|
readonly=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
pos_hide_cart_images = fields.Boolean(
|
||||||
|
string="Hide Product Images in Cart",
|
||||||
|
related='pos_config_id.hide_cart_images',
|
||||||
|
readonly=False
|
||||||
|
)
|
||||||
|
|||||||
@ -37,7 +37,13 @@ patch(OrderDisplay.prototype, {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let orderScrollTimeout = null;
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
|
if (orderScrollTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
orderScrollTimeout = setTimeout(() => {
|
||||||
|
orderScrollTimeout = null;
|
||||||
if (
|
if (
|
||||||
scrollable.scrollTop + scrollable.clientHeight >=
|
scrollable.scrollTop + scrollable.clientHeight >=
|
||||||
scrollable.scrollHeight - 100
|
scrollable.scrollHeight - 100
|
||||||
@ -46,6 +52,7 @@ patch(OrderDisplay.prototype, {
|
|||||||
this.state.displayedCount += 20;
|
this.state.displayedCount += 20;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, 150);
|
||||||
};
|
};
|
||||||
scrollable.addEventListener("scroll", onScroll);
|
scrollable.addEventListener("scroll", onScroll);
|
||||||
|
|
||||||
|
|||||||
12
static/src/app/components/orderline/orderline_patch.xml
Normal file
12
static/src/app/components/orderline/orderline_patch.xml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
<t t-inherit="point_of_sale.Orderline" t-inherit-mode="extension" owl="1">
|
||||||
|
<xpath expr="//div[hasclass('o_line_container')]" position="attributes">
|
||||||
|
<attribute name="t-if">vals.productImage and !pos.config.hide_cart_images</attribute>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//div[hasclass('o_line_container')]/img" position="attributes">
|
||||||
|
<attribute name="loading">lazy</attribute>
|
||||||
|
<attribute name="decoding">async</attribute>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
<t t-inherit="point_of_sale.ProductCard" t-inherit-mode="extension" owl="1">
|
||||||
|
<xpath expr="//img" position="attributes">
|
||||||
|
<attribute name="loading">lazy</attribute>
|
||||||
|
<attribute name="decoding">async</attribute>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@ -3,6 +3,7 @@
|
|||||||
import { NoteButton, InternalNoteButton } from "@point_of_sale/app/screens/product_screen/control_buttons/orderline_note_button/orderline_note_button";
|
import { NoteButton, InternalNoteButton } from "@point_of_sale/app/screens/product_screen/control_buttons/orderline_note_button/orderline_note_button";
|
||||||
import { Orderline } from "@point_of_sale/app/components/orderline/orderline";
|
import { Orderline } from "@point_of_sale/app/components/orderline/orderline";
|
||||||
import { patch } from "@web/core/utils/patch";
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import { usePos } from "@point_of_sale/app/hooks/pos_hook";
|
||||||
|
|
||||||
// 1. Patch NoteButton to fix line-splitting bug for customer notes in standard Odoo
|
// 1. Patch NoteButton to fix line-splitting bug for customer notes in standard Odoo
|
||||||
patch(NoteButton.prototype, {
|
patch(NoteButton.prototype, {
|
||||||
@ -64,8 +65,13 @@ patch(InternalNoteButton.prototype, {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Patch Orderline to prevent UI crashes if note contains invalid JSON
|
// 3. Patch Orderline to prevent UI crashes if note contains invalid JSON and inject pos service
|
||||||
patch(Orderline.prototype, {
|
patch(Orderline.prototype, {
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
this.pos = usePos();
|
||||||
|
},
|
||||||
|
|
||||||
get lineScreenValues() {
|
get lineScreenValues() {
|
||||||
const originalParse = JSON.parse;
|
const originalParse = JSON.parse;
|
||||||
JSON.parse = function (str) {
|
JSON.parse = function (str) {
|
||||||
|
|||||||
@ -10,17 +10,24 @@ patch(ProductScreen.prototype, {
|
|||||||
this.state.displayedProductsCount = 80;
|
this.state.displayedProductsCount = 80;
|
||||||
|
|
||||||
const originalOnScroll = this.onScroll;
|
const originalOnScroll = this.onScroll;
|
||||||
|
let scrollTimeout = null;
|
||||||
this.onScroll = (ev) => {
|
this.onScroll = (ev) => {
|
||||||
if (originalOnScroll) {
|
if (originalOnScroll) {
|
||||||
originalOnScroll(ev);
|
originalOnScroll(ev);
|
||||||
}
|
}
|
||||||
|
if (scrollTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollTimeout = setTimeout(() => {
|
||||||
|
scrollTimeout = null;
|
||||||
const el = ev.target;
|
const el = ev.target;
|
||||||
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 400) {
|
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 450) {
|
||||||
const totalCount = this.pos.productToDisplayByCateg.reduce((acc, cat) => acc + cat[1].length, 0);
|
const totalCount = this.pos.productsToDisplay.length;
|
||||||
if (this.state.displayedProductsCount < totalCount) {
|
if (this.state.displayedProductsCount < totalCount) {
|
||||||
this.state.displayedProductsCount += 80;
|
this.state.displayedProductsCount += 80;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
|
|||||||
@ -4,11 +4,31 @@ import { PosStore } from "@point_of_sale/app/services/pos_store";
|
|||||||
import { patch } from "@web/core/utils/patch";
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
patch(PosStore.prototype, {
|
patch(PosStore.prototype, {
|
||||||
|
clearProductsCache() {
|
||||||
|
this._productsToDisplayCacheKey = null;
|
||||||
|
this._productsToDisplayCache = null;
|
||||||
|
this._productToDisplayByCategCacheKey = null;
|
||||||
|
this._productToDisplayByCategCache = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async editProduct(product) {
|
||||||
|
this.clearProductsCache();
|
||||||
|
return await super.editProduct(...arguments);
|
||||||
|
},
|
||||||
|
|
||||||
get productsToDisplay() {
|
get productsToDisplay() {
|
||||||
|
const searchWord = this.searchProductWord.trim();
|
||||||
|
const categoryId = this.selectedCategory?.id || 0;
|
||||||
|
const templatesLength = this.models["product.template"].length;
|
||||||
|
const cacheKey = `${searchWord}_${categoryId}_${templatesLength}`;
|
||||||
|
|
||||||
|
if (this._productsToDisplayCacheKey === cacheKey && this._productsToDisplayCache) {
|
||||||
|
return this._productsToDisplayCache;
|
||||||
|
}
|
||||||
|
|
||||||
// We override this getter to remove Odoo's hardcoded 100-product limit.
|
// We override this getter to remove Odoo's hardcoded 100-product limit.
|
||||||
// Because our custom UI optimization module uses progressive rendering (lazy loading)
|
// Because our custom UI optimization module uses progressive rendering (lazy loading)
|
||||||
// on scroll, we can safely return the entire product list without risking browser freezing.
|
// on scroll, we can safely return the entire product list without risking browser freezing.
|
||||||
const searchWord = this.searchProductWord.trim();
|
|
||||||
let recordIterator;
|
let recordIterator;
|
||||||
const isSearchByWord = searchWord !== "";
|
const isSearchByWord = searchWord !== "";
|
||||||
|
|
||||||
@ -56,26 +76,43 @@ patch(PosStore.prototype, {
|
|||||||
filteredList.push(p);
|
filteredList.push(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let result = [];
|
||||||
if (
|
if (
|
||||||
!isSearchByWord &&
|
!isSearchByWord &&
|
||||||
!this.selectedCategory?.id &&
|
!this.selectedCategory?.id &&
|
||||||
this.areAllProductsSpecial(filteredList)
|
this.areAllProductsSpecial(filteredList)
|
||||||
) {
|
) {
|
||||||
return [];
|
result = [];
|
||||||
|
} else {
|
||||||
|
result = this.orderProductBySequenceAndFav(filteredList);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.orderProductBySequenceAndFav(filteredList);
|
this._productsToDisplayCacheKey = cacheKey;
|
||||||
|
this._productsToDisplayCache = result;
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
get productToDisplayByCateg() {
|
get productToDisplayByCateg() {
|
||||||
|
const searchWord = this.searchProductWord.trim();
|
||||||
|
const categoryId = this.selectedCategory?.id || 0;
|
||||||
|
const templatesLength = this.models["product.template"].length;
|
||||||
|
const categoriesLength = this.models["pos.category"].length;
|
||||||
|
const cacheKey = `${searchWord}_${categoryId}_${templatesLength}_${categoriesLength}`;
|
||||||
|
|
||||||
|
if (this._productToDisplayByCategCacheKey === cacheKey && this._productToDisplayByCategCache) {
|
||||||
|
return this._productToDisplayByCategCache;
|
||||||
|
}
|
||||||
|
|
||||||
// We override this getter to remove Odoo's hardcoded 100-product category limit.
|
// We override this getter to remove Odoo's hardcoded 100-product category limit.
|
||||||
const sortedProducts = this.productsToDisplay;
|
const sortedProducts = this.productsToDisplay;
|
||||||
if (!this.config.iface_group_by_categ) {
|
if (!this.config.iface_group_by_categ) {
|
||||||
return sortedProducts.length ? [["0", sortedProducts]] : [];
|
const result = sortedProducts.length ? [["0", sortedProducts]] : [];
|
||||||
|
this._productToDisplayByCategCacheKey = cacheKey;
|
||||||
|
this._productToDisplayByCategCache = result;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
const searchWord = this.searchProductWord.trim();
|
|
||||||
const byCateg = this.models["product.template"].toRaw().getAllBy("pos_categ_ids");
|
const byCateg = this.models["product.template"].toRaw().getAllBy("pos_categ_ids");
|
||||||
const selectedCategoryIds = !this.selectedCategory
|
const selectedCategoryIds = !this.selectedCategory
|
||||||
? this.models["pos.category"].map((c) => c.id)
|
? this.models["pos.category"].map((c) => c.id)
|
||||||
@ -117,6 +154,8 @@ patch(PosStore.prototype, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._productToDisplayByCategCacheKey = cacheKey;
|
||||||
|
this._productToDisplayByCategCache = results;
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -415,3 +415,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
GENERAL POS PERFORMANCE OPTIMIZATIONS (All Display Modes)
|
||||||
|
============================================================ */
|
||||||
|
.pos .overflow-y-auto {
|
||||||
|
will-change: scroll-position;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos .product {
|
||||||
|
/* Disable transitions/animations on low-end ARM devices to make UI snappier */
|
||||||
|
transition: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
transform: translateZ(0); /* GPU acceleration */
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos .product-img img {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|||||||
@ -9,6 +9,9 @@
|
|||||||
<setting string="Hide Cash In/Out (Navbar)" help="Hide Cash In/Out button in POS Navbar">
|
<setting string="Hide Cash In/Out (Navbar)" help="Hide Cash In/Out button in POS Navbar">
|
||||||
<field name="pos_hide_cash_in_out_button"/>
|
<field name="pos_hide_cash_in_out_button"/>
|
||||||
</setting>
|
</setting>
|
||||||
|
<setting string="Hide Product Images in Cart" help="Hide product images in the order/cart lines for faster rendering">
|
||||||
|
<field name="pos_hide_cart_images"/>
|
||||||
|
</setting>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user