diff --git a/addons/point_of_sale/__manifest__.py b/addons/point_of_sale/__manifest__.py index 5c5b75e7d7ea3..314187c3d4644 100644 --- a/addons/point_of_sale/__manifest__.py +++ b/addons/point_of_sale/__manifest__.py @@ -90,6 +90,8 @@ 'point_of_sale/static/src/app/models/utils/indexed_db.js', 'point_of_sale/static/src/app/models/data_service_options.js', 'point_of_sale/static/src/utils.js', + 'point_of_sale/static/src/proxy_trap.js', + 'point_of_sale/static/src/lazy_getter.js', 'point_of_sale/static/src/app/services/data_service.js', 'point_of_sale/static/tests/unit/**/*', ], diff --git a/addons/point_of_sale/static/src/app/models/pos_category.js b/addons/point_of_sale/static/src/app/models/pos_category.js index e058b73b2e702..02a0fe585d374 100644 --- a/addons/point_of_sale/static/src/app/models/pos_category.js +++ b/addons/point_of_sale/static/src/app/models/pos_category.js @@ -30,6 +30,14 @@ export class PosCategory extends Base { return parents.reverse(); } + get associatedProducts() { + const allCategoryIds = this.getAllChildren().map((cat) => cat.id); + const products = allCategoryIds.flatMap( + (catId) => this.models["product.template"].getBy("pos_categ_ids", catId) || [] + ); + // Remove duplicates since owl doesn't like them. + return Array.from(new Set(products)); + } } registry.category("pos_available_models").add(PosCategory.pythonModel, PosCategory); diff --git a/addons/point_of_sale/static/src/app/models/pos_order.js b/addons/point_of_sale/static/src/app/models/pos_order.js index 0641d36a3c116..f5c9ad6a1d29d 100644 --- a/addons/point_of_sale/static/src/app/models/pos_order.js +++ b/addons/point_of_sale/static/src/app/models/pos_order.js @@ -673,7 +673,7 @@ export class PosOrder extends Base { getTaxDetails() { const taxDetails = {}; for (const line of this.lines) { - for (const taxData of line.getAllPrices().taxesData) { + for (const taxData of line.allPrices.taxesData) { const taxId = taxData.id; if (!taxDetails[taxId]) { taxDetails[taxId] = Object.assign({}, taxData, { diff --git a/addons/point_of_sale/static/src/app/models/pos_order_line.js b/addons/point_of_sale/static/src/app/models/pos_order_line.js index 8e0d9353cbca3..30fe1418f125c 100644 --- a/addons/point_of_sale/static/src/app/models/pos_order_line.js +++ b/addons/point_of_sale/static/src/app/models/pos_order_line.js @@ -361,19 +361,19 @@ export class PosOrderline extends Base { return window.parseFloat(roundDecimals(this.price_unit || 0, digits).toFixed(digits)); } - getUnitDisplayPrice() { + get unitDisplayPrice() { if (this.config.iface_tax_included === "total") { - return this.getAllPrices(1).priceWithTax; + return this.allUnitPrices.priceWithTax; } else { - return this.getAllPrices(1).priceWithoutTax; + return this.allUnitPrices.priceWithoutTax; } } getUnitDisplayPriceBeforeDiscount() { if (this.config.iface_tax_included === "total") { - return this.getAllPrices(1).priceWithTaxBeforeDiscount; + return this.allUnitPrices.priceWithTaxBeforeDiscount; } else { - return this.getAllPrices(1).priceWithoutTaxBeforeDiscount; + return this.allUnitPrices.priceWithoutTaxBeforeDiscount; } } getBasePrice() { @@ -422,19 +422,19 @@ export class PosOrderline extends Base { } getPriceWithoutTax() { - return this.getAllPrices().priceWithoutTax; + return this.allPrices.priceWithoutTax; } getPriceWithTax() { - return this.getAllPrices().priceWithTax; + return this.allPrices.priceWithTax; } getTax() { - return this.getAllPrices().tax; + return this.allPrices.tax; } getTaxDetails() { - return this.getAllPrices().taxDetails; + return this.allPrices.taxDetails; } getTotalTaxesIncludedInPrice() { @@ -516,6 +516,14 @@ export class PosOrderline extends Base { }; } + get allPrices() { + return this.getAllPrices(); + } + + get allUnitPrices() { + return this.getAllPrices(1); + } + displayDiscountPolicy() { // Sales dropped `discount_policy`, and we only show discount if applied pricelist rule // is a percentage discount. However we don't have that information in pos @@ -584,17 +592,17 @@ export class PosOrderline extends Base { getComboTotalPrice() { const allLines = this.getAllLinesInCombo(); - return allLines.reduce((total, line) => total + line.getAllPrices(1).priceWithTax, 0); + return allLines.reduce((total, line) => total + line.allUnitPrices.priceWithTax, 0); } getComboTotalPriceWithoutTax() { const allLines = this.getAllLinesInCombo(); - return allLines.reduce((total, line) => total + line.getAllPrices(1).priceWithoutTax, 0); + return allLines.reduce((total, line) => total + line.allUnitPrices.priceWithoutTax, 0); } getOldUnitDisplayPrice() { return ( this.displayDiscountPolicy() === "without_discount" && - roundCurrency(this.getUnitDisplayPrice(), this.currency) < + roundCurrency(this.unitDisplayPrice, this.currency) < roundCurrency(this.getTaxedlstUnitPrice(), this.currency) && this.getTaxedlstUnitPrice() ); @@ -616,7 +624,7 @@ export class PosOrderline extends Base { price: this.getPriceString(), qty: this.getQuantityStr(), unit: this.product_id.uom_id ? this.product_id.uom_id.name : "", - unitPrice: formatCurrency(this.getUnitDisplayPrice(), this.currency), + unitPrice: formatCurrency(this.unitDisplayPrice, this.currency), oldUnitPrice: this.getOldUnitDisplayPrice() ? formatCurrency(this.getOldUnitDisplayPrice(), this.currency) : "", diff --git a/addons/point_of_sale/static/src/app/models/related_models.js b/addons/point_of_sale/static/src/app/models/related_models.js index d9e5dff20e696..ac669426a0130 100644 --- a/addons/point_of_sale/static/src/app/models/related_models.js +++ b/addons/point_of_sale/static/src/app/models/related_models.js @@ -1,5 +1,7 @@ import { reactive, toRaw } from "@odoo/owl"; import { uuidv4 } from "@point_of_sale/utils"; +import { TrapDisabler } from "@point_of_sale/proxy_trap"; +import { WithLazyGetterTrap } from "@point_of_sale/lazy_getter"; const ID_CONTAINER = {}; @@ -139,12 +141,12 @@ function processModelDefs(modelDefs) { return [inverseMap, modelDefs]; } -export class Base { - constructor({ models, records, model, proxyTrap }) { +export class Base extends WithLazyGetterTrap { + constructor({ models, records, model, traps }) { + super({ traps }); this.models = models; this.records = records; this.model = model; - return new Proxy(this, proxyTrap); } /** * Called during instantiation when the instance is fully-populated with field values. @@ -433,19 +435,6 @@ export function createRelatedModels(modelDefs, modelClasses = {}, opts = {}) { return records[model].has(id); } - // If value is more than 0, then the proxy trap is disabled. - let proxyTrapDisabled = 0; - function withoutProxyTrap(fn) { - return function (...args) { - try { - proxyTrapDisabled += 1; - return fn(...args); - } finally { - proxyTrapDisabled -= 1; - } - }; - } - /** * This check assumes that if the first element is a command, then the rest are commands. */ @@ -457,30 +446,39 @@ export function createRelatedModels(modelDefs, modelClasses = {}, opts = {}) { ); } - const proxyTraps = {}; - function getProxyTrap(model) { - if (model in proxyTraps) { - return proxyTraps[model]; - } + const disabler = new TrapDisabler(); + function withoutProxyTrap(fn) { + return (...args) => disabler.call(fn, ...args); + } + + const setTrapsCache = {}; + function instantiateModel(model, { models, records }) { const fields = getFields(model); - const proxyTrap = { - set(target, prop, value) { - if (proxyTrapDisabled || !(prop in fields)) { - return Reflect.set(target, prop, value); + const Model = modelClasses[model] || Base; + if (!(model in setTrapsCache)) { + setTrapsCache[model] = function setTrap(target, prop, value, receiver) { + if (disabler.isDisabled() || !(prop in fields)) { + return Reflect.set(target, prop, value, receiver); } - const field = fields[prop]; - if (field && X2MANY_TYPES.has(field.type)) { - if (!isX2ManyCommands(value)) { - value = [["clear"], ["link", ...value]]; + return disabler.call(() => { + const field = fields[prop]; + if (field && X2MANY_TYPES.has(field.type)) { + if (!isX2ManyCommands(value)) { + value = [["clear"], ["link", ...value]]; + } } - } - target.update({ [prop]: value }); - target.model.triggerEvents("update", { field: prop, value, id: target.id }); - return true; - }, - }; - proxyTraps[model] = proxyTrap; - return proxyTrap; + receiver.update({ [prop]: value }); + target.model.triggerEvents("update", { field: prop, value, id: target.id }); + return true; + }); + }; + } + return new Model({ + models, + records, + model: models[model], + traps: { set: setTrapsCache[model] }, + }); } const create = withoutProxyTrap(_create); @@ -495,9 +493,7 @@ export function createRelatedModels(modelDefs, modelClasses = {}, opts = {}) { vals["id"] = uuid(model); } - const Model = modelClasses[model] || Base; - const proxyTrap = getProxyTrap(model); - let record = new Model({ models, records, model: models[model], proxyTrap }); + let record = instantiateModel(model, { models, records }); const id = vals["id"]; record.id = id; diff --git a/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.js b/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.js index d5e8e50cf0bef..2e65629946186 100644 --- a/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.js +++ b/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.js @@ -17,13 +17,11 @@ import { Orderline } from "@point_of_sale/app/components/orderline/orderline"; import { OrderWidget } from "@point_of_sale/app/components/order_widget/order_widget"; import { OrderSummary } from "@point_of_sale/app/screens/product_screen/order_summary/order_summary"; import { ProductInfoPopup } from "@point_of_sale/app/components/popups/product_info_popup/product_info_popup"; -import { fuzzyLookup } from "@web/core/utils/search"; import { ProductCard } from "@point_of_sale/app/components/product_card/product_card"; import { ControlButtons, ControlButtonsPopup, } from "@point_of_sale/app/screens/product_screen/control_buttons/control_buttons"; -import { unaccent } from "@web/core/utils/strings"; import { CameraBarcodeScanner } from "@point_of_sale/app/screens/product_screen/camera_barcode_scanner"; export class ProductScreen extends Component { @@ -287,75 +285,6 @@ export class ProductScreen extends Component { return this.pos.searchProductWord.trim(); } - get products() { - return this.pos.models["product.template"].getAll(); - } - - get productsToDisplay() { - let list = []; - - if (this.searchWord !== "") { - list = this.addMainProductsToDisplay(this.getProductsBySearchWord(this.searchWord)); - } else if (this.pos.selectedCategory?.id) { - list = this.getProductsByCategory(this.pos.selectedCategory); - } else { - list = this.products; - } - - if (!list || list.length === 0) { - return []; - } - - const excludedProductIds = [ - this.pos.config.tip_product_id?.id, - ...this.pos.hiddenProductIds, - ...this.pos.session._pos_special_products_ids, - ]; - - list = list - .filter( - (product) => !excludedProductIds.includes(product.id) && product.available_in_pos - ) - .slice(0, 100); - - return this.searchWord !== "" - ? list.sort((a, b) => b.is_favorite - a.is_favorite) - : list.sort((a, b) => { - if (b.is_favorite !== a.is_favorite) { - return b.is_favorite - a.is_favorite; - } - return a.display_name.localeCompare(b.display_name); - }); - } - - getProductsBySearchWord(searchWord) { - return fuzzyLookup(unaccent(searchWord, false), this.products, (product) => - unaccent(product.searchString, false) - ); - } - - addMainProductsToDisplay(products) { - const uniqueProductsMap = new Map(); - for (const product of products) { - if (product.id in this.pos.mainProductVariant) { - const mainProduct = this.pos.mainProductVariant[product.id]; - uniqueProductsMap.set(mainProduct.id, mainProduct); - } else { - uniqueProductsMap.set(product.id, product); - } - } - return Array.from(uniqueProductsMap.values()); - } - - getProductsByCategory(category) { - const allCategoryIds = category.getAllChildren().map((cat) => cat.id); - const products = allCategoryIds.flatMap( - (catId) => this.pos.models["product.template"].getBy("pos_categ_ids", catId) || [] - ); - // Remove duplicates since owl doesn't like it. - return Array.from(new Set(products)); - } - async onPressEnterKey() { const { searchProductWord } = this.pos; if (!searchProductWord) { diff --git a/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.xml b/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.xml index 85afa17efbfe5..9f3f65191d426 100644 --- a/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.xml +++ b/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.xml @@ -25,9 +25,9 @@
-
+
tip_product_id && line.product_id.id === tip_product_id); - const tipAmount = tipLine ? tipLine.getAllPrices().priceWithTax : 0; + const tipAmount = tipLine ? tipLine.allPrices.priceWithTax : 0; const orderAmountStr = this.env.utils.formatCurrency(orderTotalAmount - tipAmount); if (!tipAmount) { return orderAmountStr; diff --git a/addons/point_of_sale/static/src/app/services/pos_store.js b/addons/point_of_sale/static/src/app/services/pos_store.js index 299b207e1837c..61ab381e94d07 100644 --- a/addons/point_of_sale/static/src/app/services/pos_store.js +++ b/addons/point_of_sale/static/src/app/services/pos_store.js @@ -6,7 +6,6 @@ import { floatIsZero } from "@web/core/utils/numbers"; import { registry } from "@web/core/registry"; import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { deduceUrl, random5Chars, uuidv4, getOnNotified, Counter } from "@point_of_sale/utils"; -import { Reactive } from "@web/core/utils/reactive"; import { HWPrinter } from "@point_of_sale/app/utils/printer/hw_printer"; import { ConnectionAbortedError, ConnectionLostError, RPCError } from "@web/core/network/rpc"; import { OrderReceipt } from "@point_of_sale/app/screens/receipt_screen/receipt/order_receipt"; @@ -34,8 +33,11 @@ import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog"; import { CashMovePopup } from "@point_of_sale/app/components/popups/cash_move_popup/cash_move_popup"; import { ClosePosPopup } from "@point_of_sale/app/components/popups/closing_popup/closing_popup"; import { user } from "@web/core/user"; +import { fuzzyLookup } from "@web/core/utils/search"; +import { unaccent } from "@web/core/utils/strings"; +import { WithLazyGetterTrap } from "@point_of_sale/lazy_getter"; -export class PosStore extends Reactive { +export class PosStore extends WithLazyGetterTrap { loadingSkipButtonIsShown = false; mainScreen = { name: null, component: null }; @@ -53,9 +55,9 @@ export class PosStore extends Reactive { "alert", "mail.sound_effects", ]; - constructor() { - super(); - this.ready = this.setup(...arguments).then(() => this); + constructor({ traps, env, deps }) { + super({ traps }); + this.ready = this.setup(env, deps).then(() => this); } // use setup instead of constructor because setup can be patched. async setup( @@ -1890,6 +1892,62 @@ export class PosStore extends Reactive { getDisplayDeviceIP() { return this.config.proxy_ip; } + + addMainProductsToDisplay(products) { + const uniqueProductsMap = new Map(); + for (const product of products) { + if (product.id in this.mainProductVariant) { + const mainProduct = this.mainProductVariant[product.id]; + uniqueProductsMap.set(mainProduct.id, mainProduct); + } else { + uniqueProductsMap.set(product.id, product); + } + } + return Array.from(uniqueProductsMap.values()); + } + + get productsToDisplay() { + const searchWord = this.searchProductWord.trim(); + const allProducts = this.models["product.template"].getAll(); + let list = []; + + if (searchWord !== "") { + list = this.addMainProductsToDisplay( + fuzzyLookup(unaccent(searchWord, false), allProducts, (product) => + unaccent(product.searchString, false) + ) + ); + } else if (this.selectedCategory?.id) { + list = this.selectedCategory.associatedProducts; + } else { + list = allProducts; + } + + if (!list || list.length === 0) { + return []; + } + + const excludedProductIds = [ + this.config.tip_product_id?.id, + ...this.hiddenProductIds, + ...this.session._pos_special_products_ids, + ]; + + list = list + .filter( + (product) => !excludedProductIds.includes(product.id) && product.available_in_pos + ) + .slice(0, 100); + + return searchWord !== "" + ? list.sort((a, b) => b.is_favorite - a.is_favorite) + : list.sort((a, b) => { + if (b.is_favorite !== a.is_favorite) { + return b.is_favorite - a.is_favorite; + } + return a.display_name.localeCompare(b.display_name); + }); + } } PosStore.prototype.electronic_payment_interfaces = {}; @@ -1914,7 +1972,7 @@ export function register_payment_method(use_payment_terminal, ImplementedPayment export const posService = { dependencies: PosStore.serviceDependencies, async start(env, deps) { - return new PosStore(env, deps).ready; + return new PosStore({ traps: {}, env, deps }).ready; }, }; diff --git a/addons/point_of_sale/static/src/lazy_getter.js b/addons/point_of_sale/static/src/lazy_getter.js new file mode 100644 index 0000000000000..8af5a790e1b9b --- /dev/null +++ b/addons/point_of_sale/static/src/lazy_getter.js @@ -0,0 +1,102 @@ +import { effect } from "@web/core/utils/reactive"; +import { getDisabler } from "./proxy_trap"; + +function getAllGetters(proto) { + const getterNames = new Set(); + const getters = new Set(); + while (proto !== null) { + const descriptors = Object.getOwnPropertyDescriptors(proto); + for (const [name, descriptor] of Object.entries(descriptors)) { + if (descriptor.get && !getterNames.has(name)) { + getterNames.add(name); + getters.add([name, descriptor.get]); + } + } + proto = Object.getPrototypeOf(proto); + } + return getters; +} + +const classGetters = new Map(); + +export function clearGettersCache() { + classGetters.clear(); +} + +function getGetters(Class) { + if (!classGetters.has(Class)) { + const getters = new Map(); + for (const [name, func] of getAllGetters(Class.prototype)) { + if (name.startsWith("__") && name.endsWith("__")) { + continue; + } + getters.set(name, [ + `__lazy_${name}`, + (obj) => { + return func.call(obj); + }, + ]); + } + classGetters.set(Class, getters); + } + return classGetters.get(Class); +} + +function defineLazyGetterTrap(Class) { + const getters = getGetters(Class); + return function get(target, prop, receiver) { + const disabler = getDisabler(target, prop); + if (disabler.isDisabled() || !getters.has(prop)) { + return Reflect.get(target, prop, receiver); + } + return disabler.call(() => { + const [lazyName] = getters.get(prop); + // For a getter, we should get the value from the receiver. + // Because the receiver is linked to the reactivity. + // We want to read the getter from it to make sure that the getter + // is part of the reactivity as well. + // To avoid infinite recursion, we disable this proxy trap + // during the time the lazy getter is accessed. + return receiver[lazyName]; + }); + }; +} + +function lazyComputed(obj, propName, compute) { + const key = Symbol(propName); + Object.defineProperty(obj, propName, { + get() { + return this[key](); + }, + configurable: true, + }); + + /** + * - `recompute` depends on the dependencies of `compute`. + * - When one of the dependencies of `compute` changed, `recompute` invalidates the cache of the `compute`. + * - The cache of `compute` is saved in `value`. + */ + effect( + function recompute(obj) { + const value = []; + obj[key] = () => { + if (!value.length) { + value.push(compute(obj)); + } + return value[0]; + }; + }, + [obj] + ); +} + +export class WithLazyGetterTrap { + constructor({ traps }) { + const Class = this.constructor; + const instance = new Proxy(this, { get: defineLazyGetterTrap(Class), ...traps }); + for (const [lazyName, func] of getGetters(Class).values()) { + lazyComputed(instance, lazyName, func); + } + return instance; + } +} diff --git a/addons/point_of_sale/static/src/proxy_trap.js b/addons/point_of_sale/static/src/proxy_trap.js new file mode 100644 index 0000000000000..92a3480c143bf --- /dev/null +++ b/addons/point_of_sale/static/src/proxy_trap.js @@ -0,0 +1,39 @@ +/** + * Instance of this class is useful in a context of a proxy trap handler. + * A trap handler's main purpose is to introduce a new behavior which is prone to + * being recursive. In this case, to avoid infinite recursion, we should be able to track the + * stack of calls and allow the handler to call the default behavior via the Reflect API. + * + * The idea is that when a block of code is called via the `call` method, we increment a counter. + * If the counter is greater than 0, then we know that we are in a recursive call. We can then + * use the `isDisabled` method to check if we should call the default behavior or not. + */ +export class TrapDisabler { + constructor() { + this.disabled = 0; + } + isDisabled() { + return this.disabled > 0; + } + call(fn, ...args) { + try { + this.disabled += 1; + return fn(...args); + } finally { + this.disabled -= 1; + } + } +} + +const disablerCaches = new WeakMap(); + +export function getDisabler(target, prop) { + if (!disablerCaches.has(target)) { + disablerCaches.set(target, new Map()); + } + const disablerCache = disablerCaches.get(target); + if (!disablerCache.has(prop)) { + disablerCache.set(prop, new TrapDisabler()); + } + return disablerCache.get(prop); +} diff --git a/addons/point_of_sale/static/tests/unit/lazy_getter.test.js b/addons/point_of_sale/static/tests/unit/lazy_getter.test.js new file mode 100644 index 0000000000000..3d14f081d56e3 --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/lazy_getter.test.js @@ -0,0 +1,314 @@ +import { describe, expect, mountOnFixture, test } from "@odoo/hoot"; +import { Component, onWillRender, reactive, useState, xml } from "@odoo/owl"; +import { animationFrame } from "@odoo/hoot-mock"; +import { WithLazyGetterTrap, clearGettersCache } from "@point_of_sale/lazy_getter"; +import { patch } from "@web/core/utils/patch"; +import { zip } from "@web/core/utils/arrays"; + +/** + * This returns an object which provides a custom `step` and `verifySteps` behavior. + * See the definition of each method for more details. + */ +function makeUnorderedVerifySteps() { + let steps = []; + return { + step(val) { + steps.push(val); + }, + /** + * Makes multiple assertions: + * - Are all items in `vals` in steps? + * - Are the items in `steps` ordered according to each item in `orderedValsArr`? + * Then it clears the `steps`. + * @param {any[]} vals + * @param {any[][]} [orderedValsArr] + */ + verifySteps(vals, orderedValsArr = []) { + const stepsSet = new Set(steps); + const valsSet = new Set(vals); + vals.forEach((val) => expect(stepsSet.has(val)).toBe(true)); + steps.forEach((val) => expect(valsSet.has(val)).toBe(true)); + + orderedValsArr.forEach((orderedVals) => { + expect( + zip(orderedVals.slice(0, -1), orderedVals.slice(1)).reduce((acc, [a, b]) => { + return acc && steps.indexOf(a) < steps.indexOf(b); + }, true) + ).toEqual(true); + }); + + steps = []; + }, + }; +} + +const unorderedExpect = makeUnorderedVerifySteps(); + +class AppStore extends WithLazyGetterTrap { + constructor() { + super({ traps: {} }); + this.a = 0; + this.b = 0; + this.c = 0; + this.d = 0; + } + get ab() { + return this.a + this.b; + } + get abc() { + let result = 0; + for (let i = 0; i < 10; i++) { + result += this.ab; + } + return result + this.c; + } + get bc() { + return this.b + this.c; + } + get cd() { + return this.c + this.d; + } + get x() { + return this.abc + this.bc; + } + get y() { + return this.cd + this.x; + } +} + +class WithStore extends Component { + setup() { + this.store = useState(this.env.store); + onWillRender(() => this.onWillRender()); + } + onWillRender() {} +} + +class A extends WithStore { + static template = xml` + A: +`; +} + +class B extends WithStore { + static template = xml` + B: +`; +} + +class C extends WithStore { + static template = xml` + C: +`; +} + +class D extends WithStore { + static template = xml` + D: +`; +} + +class AB extends WithStore { + static template = xml` + AB: +`; +} + +class ABC extends WithStore { + static template = xml` + ABC: +`; +} + +class BC extends WithStore { + static template = xml` + BC: +`; +} + +class CD extends WithStore { + static template = xml` + CD: +`; +} + +class Root extends Component { + static components = { A, B, C, D, AB, ABC, BC, CD }; + static template = xml` + +`; +} + +describe("lazy getters", () => { + test("each getter should only be called once and only when needed", async () => { + const unpatch = patch(AppStore.prototype, { + get ab() { + unorderedExpect.step("ab"); + return super.ab; + }, + get abc() { + unorderedExpect.step("abc"); + return super.abc; + }, + get bc() { + unorderedExpect.step("bc"); + return super.bc; + }, + get cd() { + unorderedExpect.step("cd"); + return super.cd; + }, + }); + + const store = reactive(new AppStore()); + + await mountOnFixture(Root, { env: { store }, warnIfNoStaticProps: false }); + + unorderedExpect.verifySteps(["ab", "abc", "bc", "cd"]); + + store.a = 1; + + // Before rerendering, the getters should not be called + unorderedExpect.verifySteps([]); + + await animationFrame(); + // Only during rerendering that the getters are called + unorderedExpect.verifySteps(["ab", "abc"]); + + store.b = 1; + unorderedExpect.verifySteps([]); + await animationFrame(); + unorderedExpect.verifySteps(["bc", "ab", "abc"]); + + store.c = 1; + unorderedExpect.verifySteps([]); + await animationFrame(); + unorderedExpect.verifySteps(["cd", "bc", "abc"]); + + store.d = 1; + unorderedExpect.verifySteps([]); + await animationFrame(); + unorderedExpect.verifySteps(["cd"]); + + unpatch(); + clearGettersCache(); + }); + + test("only dependent components rerender", async () => { + const unpatches = [A, B, C, D, AB, ABC, CD, BC].map((Class) => { + return patch(Class.prototype, { + onWillRender() { + unorderedExpect.step(Class); + return super.onWillRender(); + }, + }); + }); + + const store = reactive(new AppStore()); + await mountOnFixture(Root, { env: { store }, warnIfNoStaticProps: false }); + unorderedExpect.verifySteps([A, B, C, D, AB, ABC, BC, CD]); + + store.a = 1; + await animationFrame(); + unorderedExpect.verifySteps([A, AB, ABC]); + + store.b = 1; + await animationFrame(); + unorderedExpect.verifySteps([B, AB, ABC, BC]); + + store.c = 1; + await animationFrame(); + unorderedExpect.verifySteps([C, ABC, BC, CD]); + + store.d = 1; + await animationFrame(); + unorderedExpect.verifySteps([D, CD]); + + for (const unpatch of unpatches) { + unpatch(); + } + clearGettersCache(); + }); + + test("only dependent getters are called and in correct order", () => { + clearGettersCache(); + + const unpatch = patch(AppStore.prototype, { + get ab() { + const result = super.ab; + unorderedExpect.step("ab"); + return result; + }, + get abc() { + const result = super.abc; + unorderedExpect.step("abc"); + return result; + }, + get bc() { + const result = super.bc; + unorderedExpect.step("bc"); + return result; + }, + get cd() { + const result = super.cd; + unorderedExpect.step("cd"); + return result; + }, + get x() { + const result = super.x; + unorderedExpect.step("x"); + return result; + }, + get y() { + const result = super.y; + unorderedExpect.step("y"); + return result; + }, + }); + const store = reactive(new AppStore()); + + expect(store.y).toBe(0); + unorderedExpect.verifySteps(["ab", "bc", "cd", "abc", "x", "y"], [["ab", "abc", "x", "y"]]); + + store.a = 1; + expect(store.y).toBe(10); + unorderedExpect.verifySteps(["ab", "abc", "x", "y"], [["ab", "abc", "x", "y"]]); + + store.b = 1; + expect(store.y).toBe(21); + unorderedExpect.verifySteps( + ["ab", "bc", "abc", "x", "y"], + [ + ["ab", "abc", "x", "y"], + ["bc", "x", "y"], + ] + ); + + store.c = 1; + expect(store.y).toBe(24); + unorderedExpect.verifySteps( + ["abc", "bc", "cd", "x", "y"], + [ + ["abc", "x", "y"], + ["bc", "x", "y"], + ["cd", "y"], + ] + ); + + store.d = 1; + expect(store.y).toBe(25); + unorderedExpect.verifySteps(["cd", "y"], [["cd", "y"]]); + + unpatch(); + clearGettersCache(); + }); +}); diff --git a/addons/pos_restaurant/static/src/app/models/restaurant_table.js b/addons/pos_restaurant/static/src/app/models/restaurant_table.js index 213605198da04..91d1b40429d3a 100644 --- a/addons/pos_restaurant/static/src/app/models/restaurant_table.js +++ b/addons/pos_restaurant/static/src/app/models/restaurant_table.js @@ -62,7 +62,7 @@ export class RestaurantTable extends Base { y: this.getY() + this.height / 2, }; } - get orders() { + getOrders() { return this.models["pos.order"].filter( (o) => o.table_id?.id === this.id && diff --git a/addons/pos_restaurant/static/src/app/services/pos_store.js b/addons/pos_restaurant/static/src/app/services/pos_store.js index 61d1bf0f4f885..4ae83d6cfbe94 100644 --- a/addons/pos_restaurant/static/src/app/services/pos_store.js +++ b/addons/pos_restaurant/static/src/app/services/pos_store.js @@ -219,11 +219,9 @@ patch(PosStore.prototype, { async setTable(table, orderUuid = null) { this.loadingOrderState = true; - const tableOrders = table.orders; - - let currentOrder = tableOrders.find((order) => - orderUuid ? order.uuid === orderUuid : !order.finalized - ); + let currentOrder = table + .getOrders() + .find((order) => (orderUuid ? order.uuid === orderUuid : !order.finalized)); if (currentOrder) { this.setOrder(currentOrder); @@ -244,7 +242,7 @@ patch(PosStore.prototype, { this.loadingOrderState = true; const orders = await this.syncAllOrders({ throw: true }); const orderUuids = orders.map((order) => order.uuid); - for (const order of table.orders) { + for (const order of table.getOrders()) { if ( !orderUuids.includes(order.uuid) && typeof order.id === "number" && diff --git a/addons/pos_self_order/__manifest__.py b/addons/pos_self_order/__manifest__.py index a218e073c7c32..3052a97ae3f65 100644 --- a/addons/pos_self_order/__manifest__.py +++ b/addons/pos_self_order/__manifest__.py @@ -38,6 +38,8 @@ 'web/static/src/core/currency.js', 'barcodes/static/src/barcode_service.js', 'point_of_sale/static/src/utils.js', + 'point_of_sale/static/src/proxy_trap.js', + 'point_of_sale/static/src/lazy_getter.js', 'web/static/lib/bootstrap/js/dist/util/index.js', 'web/static/lib/bootstrap/js/dist/dom/data.js', 'web/static/lib/bootstrap/js/dist/dom/event-handler.js',