diff --git a/addons/pos_restaurant/views/res_config_settings_views.xml b/addons/pos_restaurant/views/res_config_settings_views.xml index 6c2a775d4ef..0185259077e 100644 --- a/addons/pos_restaurant/views/res_config_settings_views.xml +++ b/addons/pos_restaurant/views/res_config_settings_views.xml @@ -12,7 +12,7 @@
-
diff --git a/addons/pos_self_order/__init__.py b/addons/pos_self_order/__init__.py new file mode 100644 index 00000000000..dfccc6b661b --- /dev/null +++ b/addons/pos_self_order/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import controllers +from . import models +from . import tests diff --git a/addons/pos_self_order/__manifest__.py b/addons/pos_self_order/__manifest__.py new file mode 100644 index 00000000000..79f187de5bc --- /dev/null +++ b/addons/pos_self_order/__manifest__.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +{ + 'name': "POS Self Order", + + 'summary': """ + Addon for the POS App that allows customers to view the menu on their smartphone. + """, + + 'category': 'Sales/Point Of Sale', + + 'depends': ['pos_restaurant', 'http_routing'], + 'auto_install': ['pos_restaurant'], + + 'demo': [ + 'demo/pos_restaurant_demo.xml', + ], + 'data': [ + 'security/ir.model.access.csv', + 'views/index.xml', + 'views/qr_code.xml', + 'views/custom_link_views.xml', + 'data/custom_link_data.xml', + 'views/res_config_settings_views.xml', + 'views/point_of_sale_dashboard.xml', + ], + 'assets': { + 'pos_self_order.assets_self_order': [ + ('include', 'web._assets_helpers'), + ('include', 'web._assets_backend_helpers'), + ('include', 'web._assets_primary_variables'), + 'web/static/src/scss/pre_variables.scss', + 'web/static/lib/bootstrap/scss/_functions.scss', + 'web/static/lib/bootstrap/scss/_variables.scss', + ('include', 'web._assets_bootstrap'), + 'web/static/src/boot.js', + 'web/static/src/env.js', + 'web/static/src/session.js', + 'web/static/src/core/utils/transitions.scss', + 'web/static/src/core/**/*', + 'web/static/lib/owl/owl.js', + 'web/static/lib/owl/odoo_module.js', + 'web/static/lib/luxon/luxon.js', + + 'web/static/src/views/fields/formatters.js', + 'web/static/src/libs/fontawesome/css/font-awesome.css', + 'web/static/lib/odoo_ui_icons/*', + + 'pos_self_order/static/src/**/*', + 'point_of_sale/static/src/utils.js', + ], + 'pos_self_order.assets_tests': [ + 'web/static/lib/jquery/jquery.js', + 'web/static/lib/underscore/underscore.js', + 'web_tour/static/src/tour_pointer/**/*.js', + 'web_tour/static/src/tour_service/**/*.js', + 'pos_self_order/static/tests/tours/**/*', + ], + }, + 'license': 'LGPL-3', +} diff --git a/addons/pos_self_order/controllers/__init__.py b/addons/pos_self_order/controllers/__init__.py new file mode 100644 index 00000000000..b0f26a9a602 --- /dev/null +++ b/addons/pos_self_order/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import controllers diff --git a/addons/pos_self_order/controllers/controllers.py b/addons/pos_self_order/controllers/controllers.py new file mode 100644 index 00000000000..9adb2c6f106 --- /dev/null +++ b/addons/pos_self_order/controllers/controllers.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- + +from typing import List, Dict, Optional +import werkzeug + +from odoo import http +from odoo.http import request + +from odoo.addons.http_routing.models.ir_http import unslug +from odoo.addons.point_of_sale.models.product import ProductProduct +from odoo.addons.pos_self_order.models.pos_config import PosConfig + + +class PosSelfOrderController(http.Controller): + """ + This is the controller for the POS Self Order App + There is one main route that the client will use to access the POS Self Order App: /menu + This route will render the LANDING PAGE of the POS Self Order App + And it will pass the needed data to the template: the list of products, pos_config_id, table_id, company_name, currency... + After that the client will be able to navigate to the /products route w/o aditional requests + to the server, using client side routing. + """ + + @http.route("/menu", auth="public") + def pos_self_order_redirect(self): + pos_config_id = self._get_any_pos_config_with_qr_code_menu_sudo().id + return request.redirect(f"/menu/{pos_config_id}") + + @http.route( + [ + "/menu/", + "/menu//products", + "/menu//products/", + ], + auth="public", + website=True, + sitemap=True + ) + def pos_self_order_start(self, pos_name: str): + """ + The user gets this route from the QR code that they scan at the table + :param pos_name: the name of the pos config: can be the id or the slugified name of the pos config + :return: the rendered template + """ + _, pos_config_id = unslug(pos_name) + pos_config_sudo = self._get_pos_config_sudo(pos_config_id) + session_info = request.env["ir.http"].get_frontend_session_info() + session_info.update({ + "currencies": request.env["ir.http"].get_currencies(), + "pos_self_order": self._get_self_order_config(pos_config_sudo), + }) + response = request.render("pos_self_order.index", {"session_info": session_info}) + return response + + @http.route( + [ + "/menu/get-image/", + "/menu/get-image//", + ], + methods=["GET"], + type="http", + auth="public", + ) + def pos_self_order_get_image(self, product_id: int, image_size: Optional[int] = None): + """ + This is the route that the POS Self Order App uses to GET THE PRODUCT IMAGES + :return: the image of the product + :rtype: binary + """, + # The idea here is to not show the image if there is no pos with the self order menu + if not self._get_any_pos_config_with_qr_code_menu_sudo(): + raise werkzeug.exceptions.NotFound() + + product_sudo = request.env["product.product"].sudo().browse(product_id) + if not product_sudo.available_in_pos: + raise werkzeug.exceptions.NotFound() + + if image_size == 1920 and product_sudo.image_1920: + return ( + request.env["ir.binary"] + ._get_image_stream_from(product_sudo, field_name="image_1920") + .get_response() + ) + return ( + request.env["ir.binary"] + ._get_image_stream_from(product_sudo, field_name="image_128") + .get_response() + ) + + @http.route("/menu/get-bg-image", methods=["GET"], type="http", auth="public") + def pos_self_order_get_bg_image(self, pos_config_id: int): + """ + Gets the background image for this self order + :return: the bg image + :rtype: binary + """, + pos_config_sudo = self._get_pos_config_sudo(pos_config_id) + if not pos_config_sudo.self_order_image: + raise werkzeug.exceptions.NotFound() + return ( + request.env["ir.binary"] + ._get_image_stream_from(pos_config_sudo, field_name="self_order_image") + .get_response() + ) + + def _get_any_pos_config_with_qr_code_menu_sudo(self) -> PosConfig: + """ + Returns a PosConfig that allows the QR code menu, if there is one, + or raises a NotFound otherwise + """ + pos_config_sudo = ( + request.env["pos.config"] + .sudo() + .search([("self_order_view_mode", "=", True)], limit=1) + ) + if not pos_config_sudo: + raise werkzeug.exceptions.NotFound() + return pos_config_sudo + + def _get_pos_config_sudo(self, pos_config_id: int) -> PosConfig: + """ + Returns the PosConfig if pos_config_id exist and the pos is configured to allow the menu to be viewed online. + If not, it raises a NotFound + """ + pos_config_sudo = request.env["pos.config"].sudo().browse( + int(pos_config_id)) + if not pos_config_sudo or not pos_config_sudo.self_order_view_mode: + raise werkzeug.exceptions.NotFound() + return pos_config_sudo + + def _get_self_order_config(self, pos_config_sudo: PosConfig) -> Dict: + """ + Returns the necessary information for the POS Self Order App + """ + return { + "pos_config_id": pos_config_sudo.id, + "company_name": pos_config_sudo.company_id.name, + "currency_id": pos_config_sudo.currency_id.id, + "show_prices_with_tax_included": pos_config_sudo.iface_tax_included == "total", + "custom_links": self._get_custom_links(pos_config_sudo.id), + "attributes_by_ptal_id": request.env["pos.session"].sudo()._get_attributes_by_ptal_id(), + "products": self._get_data_from_products(self._get_available_products_sudo(pos_config_sudo.id), pos_config_sudo.id), + } + + def _get_custom_links(self, pos_config_id: int) -> List[Dict[str, str]]: + """ + On the landing page of the app we can have a number of custom links + that are defined by the restaurant employee in the backend. + This function returns a list of dictionaries with the attributes of each link + that is available for the POS with id pos_config_id. + """ + domain = [ + "|", + ("pos_config_ids", "in", [pos_config_id]), + ("pos_config_ids", "=", False), + ] + return ( + request.env["pos_self_order.custom_link"] + .sudo() + .search_read(domain, fields=["name", "url", "style"], order="sequence") + ) + + def _get_available_products_sudo(self, pos_config_id: int) -> ProductProduct: + """ + This function returns the products that are available in the POS with id pos_config_id + """ + domain = [("available_in_pos", "=", True)] + pos_config_sudo = self._get_pos_config_sudo(pos_config_id) + if (pos_config_sudo.limit_categories and pos_config_sudo.iface_available_categ_ids): + domain.append(("pos_categ_id", "in", pos_config_sudo.iface_available_categ_ids.ids)) + return ( + request.env["product.product"] + .sudo() + .search(domain, order="pos_categ_id.sequence asc nulls last") + ) + + def _get_data_from_products(self, products_sudo: ProductProduct, pos_config_id: int) -> List[Dict[str, str]]: + """ + This function adds the price info to each product in the list products_sudo + and returns the list of products with the necessary info + """ + return [ + { + "price_info": product.get_product_info_pos( + product.lst_price, 1, int(pos_config_id) + )["all_prices"], + "has_image": bool(product.image_1920), + **product.read( + [ + "id", + "display_name", + "description_sale", + "pos_categ_id", + "attribute_line_ids", + ] + )[0], + } + for product in products_sudo + ] diff --git a/addons/pos_self_order/data/custom_link_data.xml b/addons/pos_self_order/data/custom_link_data.xml new file mode 100644 index 00000000000..1a440a52e0b --- /dev/null +++ b/addons/pos_self_order/data/custom_link_data.xml @@ -0,0 +1,9 @@ + + + + + View Menu + /products + + + diff --git a/addons/pos_self_order/demo/pos_restaurant_demo.xml b/addons/pos_self_order/demo/pos_restaurant_demo.xml new file mode 100644 index 00000000000..446d6fcb5ce --- /dev/null +++ b/addons/pos_self_order/demo/pos_restaurant_demo.xml @@ -0,0 +1,6 @@ + + + + True + + diff --git a/addons/pos_self_order/models/__init__.py b/addons/pos_self_order/models/__init__.py new file mode 100644 index 00000000000..6e71cd40aef --- /dev/null +++ b/addons/pos_self_order/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +from . import res_config_settings +from . import pos_config +from . import pos_self_order_custom_link +from . import ir_http diff --git a/addons/pos_self_order/models/ir_http.py b/addons/pos_self_order/models/ir_http.py new file mode 100644 index 00000000000..701f876f883 --- /dev/null +++ b/addons/pos_self_order/models/ir_http.py @@ -0,0 +1,12 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class IrHttp(models.AbstractModel): + _inherit = 'ir.http' + + @classmethod + def _get_translation_frontend_modules_name(cls): + mods = super()._get_translation_frontend_modules_name() + return mods + ['pos_self_order'] diff --git a/addons/pos_self_order/models/pos_config.py b/addons/pos_self_order/models/pos_config.py new file mode 100644 index 00000000000..b5b5993958a --- /dev/null +++ b/addons/pos_self_order/models/pos_config.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class PosConfig(models.Model): + _inherit = "pos.config" + + self_order_view_mode = fields.Boolean( + string="QR Code Menu", + help="Allow customers to view the menu on their phones by scanning the QR code on the table", + ) + self_order_image = fields.Image( + string="Self Order Image", + help="Image to display on the self order screen", + max_width=1920, max_height=1080, + ) + self_order_image_name = fields.Char( + string="Self Order Image Name", + help="Name of the image to display on the self order screen", + ) + + def preview_self_order_app(self): + """ + This function is called when the user clicks on the "Preview App" button + :return: object representing the action to open the app's url in a new tab + """ + self.ensure_one() + return { + "type": "ir.actions.act_url", + "url": f"/menu/{self.id}", + "target": "new", + } diff --git a/addons/pos_self_order/models/pos_self_order_custom_link.py b/addons/pos_self_order/models/pos_self_order_custom_link.py new file mode 100644 index 00000000000..96398c981cf --- /dev/null +++ b/addons/pos_self_order/models/pos_self_order_custom_link.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, api + + +class PosSelfOrderCustomLink(models.Model): + _name = "pos_self_order.custom_link" + _description = "Custom links that the restaurant can configure to be displayed on the self order screen" + name = fields.Char(string="Label", required=True, translate=True) + url = fields.Char(string="URL", required=True) + pos_config_ids = fields.Many2many( + "pos.config", + string="Point of Sales", + domain="[('self_order_view_mode', '=', True)]", + help="Select for which points of sale you want to display this link. Leave empty to display it for all points of sale. You have to select among the points of sale that have the 'QR Code Menu' feature enabled.", + ) + style = fields.Selection( + [ + ("primary", "Primary"), + ("secondary", "Secondary"), + ("success", "Success"), + ("warning", "Warning"), + ("danger", "Danger"), + ("info", "Info"), + ("light", "Light"), + ("dark", "Dark"), + ], + string="Style", + default="primary", + required=True, + ) + link_html = fields.Html("Preview", compute="_compute_link_html", store=True, readonly=True) + sequence = fields.Integer("Sequence", default=1) + + @api.depends('name', 'style') + def _compute_link_html(self): + for link in self: + if link.name: + link.link_html = f"{link.name}" diff --git a/addons/pos_self_order/models/res_config_settings.py b/addons/pos_self_order/models/res_config_settings.py new file mode 100644 index 00000000000..1ddfe90bf66 --- /dev/null +++ b/addons/pos_self_order/models/res_config_settings.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api +from odoo.tools import split_every + + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + pos_self_order_view_mode = fields.Boolean( + compute="_compute_pos_module_pos_self_order", store=True, readonly=False) + pos_self_order_image = fields.Image( + compute="_compute_pos_module_pos_self_order", store=True, readonly=False, max_width=1920, max_height=1080) + pos_self_order_image_name = fields.Char( + compute="_compute_pos_module_pos_self_order", store=True, readonly=False) + + @api.depends("pos_config_id") + def _compute_pos_module_pos_self_order(self): + for res_config in self: + if not res_config.pos_config_id.self_order_view_mode: + res_config.update( + { + "pos_self_order_view_mode": False, + "pos_self_order_image": False, + "pos_self_order_image_name": False, + } + ) + else: + res_config.update( + { + "pos_self_order_view_mode": res_config.pos_config_id.self_order_view_mode, + "pos_self_order_image": res_config.pos_config_id.self_order_image, + "pos_self_order_image_name": res_config.pos_config_id.self_order_image_name, + } + ) + + def generate_qr_codes_page(self): + """ + Generate the data needed to print the QR codes page + """ + no_of_qr_codes_per_page = 16 + qr_codes_to_print = [ + { + "id": 0, + "url": f"{self.get_base_url()}/menu/{self.pos_config_id.id}", + } + for i in range(no_of_qr_codes_per_page) + ] + data = { + "groups_of_tables": list(split_every(4, qr_codes_to_print, list)), + } + return self.env.ref("pos_self_order.report_self_order_qr_codes_page").report_action([], data=data) + + def preview_self_order_app(self): + """ + This function calls the preview_self_order_app function of the pos.config model + """ + self.ensure_one() + return self.pos_config_id.preview_self_order_app() diff --git a/addons/pos_self_order/security/ir.model.access.csv b/addons/pos_self_order/security/ir.model.access.csv new file mode 100644 index 00000000000..7e39ce4a4e2 --- /dev/null +++ b/addons/pos_self_order/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_pos_self_order_custom_link_manager,access.pos_self_order.custom_link_manager,model_pos_self_order_custom_link,point_of_sale.group_pos_manager,1,1,1,1 +access_pos_self_order_custom_link_user,access.pos_self_order.custom_link_user,model_pos_self_order_custom_link,point_of_sale.group_pos_user,1,0,0,0 diff --git a/addons/pos_self_order/static/src/LandingPage/LandingPage.js b/addons/pos_self_order/static/src/LandingPage/LandingPage.js new file mode 100644 index 00000000000..3dc949c6421 --- /dev/null +++ b/addons/pos_self_order/static/src/LandingPage/LandingPage.js @@ -0,0 +1,13 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { useSelfOrder } from "@pos_self_order/SelfOrderService"; +import { formatMonetary } from "@web/views/fields/formatters"; +export class LandingPage extends Component { + static template = "pos_self_order.LandingPage"; + setup() { + this.selfOrder = useSelfOrder(); + this.formatMonetary = formatMonetary; + this.env.state.currentProduct = 0; + } +} diff --git a/addons/pos_self_order/static/src/LandingPage/LandingPage.xml b/addons/pos_self_order/static/src/LandingPage/LandingPage.xml new file mode 100644 index 00000000000..5b5688a469a --- /dev/null +++ b/addons/pos_self_order/static/src/LandingPage/LandingPage.xml @@ -0,0 +1,39 @@ + + + +
+
+ + + + + +
+ +
+
+ +
Powered By + +
+
+
+ diff --git a/addons/pos_self_order/static/src/NavBar/NavBar.js b/addons/pos_self_order/static/src/NavBar/NavBar.js new file mode 100644 index 00000000000..ee5b3ed4427 --- /dev/null +++ b/addons/pos_self_order/static/src/NavBar/NavBar.js @@ -0,0 +1,11 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { useSelfOrder } from "@pos_self_order/SelfOrderService"; +export class NavBar extends Component { + static template = "pos_self_order.NavBar"; + static props = { previousPage: String }; + setup() { + this.selfOrder = useSelfOrder(); + } +} diff --git a/addons/pos_self_order/static/src/NavBar/NavBar.xml b/addons/pos_self_order/static/src/NavBar/NavBar.xml new file mode 100644 index 00000000000..d38481f3b6a --- /dev/null +++ b/addons/pos_self_order/static/src/NavBar/NavBar.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/addons/pos_self_order/static/src/ProductList/ProductList.js b/addons/pos_self_order/static/src/ProductList/ProductList.js new file mode 100644 index 00000000000..4d9f2f8c925 --- /dev/null +++ b/addons/pos_self_order/static/src/ProductList/ProductList.js @@ -0,0 +1,235 @@ +/** @odoo-module */ + +import { Component, onMounted, useEffect, useRef, useState } from "@odoo/owl"; +import { useSelfOrder } from "@pos_self_order/SelfOrderService"; +import { useAutofocus } from "@web/core/utils/hooks"; +import { formatMonetary } from "@web/views/fields/formatters"; +import { NavBar } from "@pos_self_order/NavBar/NavBar"; +import { fuzzyLookup } from "@web/core/utils/search"; +export class ProductList extends Component { + static template = "pos_self_order.ProductList"; + static props = []; + static components = { + NavBar, + }; + setup() { + this.privateState = useState({ + selectedTag: "", + searchIsFocused: false, + searchInput: "", + navbarIsShown: true, + scrollingDown: false, + scrolling: false, + }); + this.selfOrder = useSelfOrder(); + this.formatMonetary = formatMonetary; + useAutofocus({ refName: "searchInput", mobile: true }); + this.productsList = useRef("productsList"); + this.currentProductCard = useRef(`product_${this.env.state.currentProduct}`); + // object with references to each tag heading + this.productGroup = Object.fromEntries( + Array.from(this.selfOrder.tagList).map((tag) => { + return [tag, useRef(`productsWithTag_${tag}`)]; + }) + ); + this.tagButtons = Object.fromEntries( + Array.from(this.selfOrder.tagList).map((tag) => { + return [tag, useRef(`tag_${tag}`)]; + }) + ); + this.tagList = useRef("tagList"); + this.header = useRef("header"); + this.productPage = useRef("productPage"); + + onMounted(() => { + this.headerHeight = this.header.el.offsetHeight; + this.navbarHeight = this.header.el.querySelector("nav").offsetHeight; + // TODO: add dvh once it is supported + this.productsList.el.style.height = `calc(100vh - ${this.navbarHeight}px)`; + this.productsList.el.style.paddingBottom = `100px`; + + // if the user is coming from the product page + // we scroll back to the product card that he was looking at before + if (this.env.state.currentProduct) { + this.scrollTo(this.currentProductCard); + } + }); + // this useEffect is used to hide the navbar when the user is scrolling down + // ( so the list looks like it's pushing the navbar up ) + useEffect( + (scrollingDown, searchIsFocused) => { + if (searchIsFocused) { + return; + } + const threshold = 60; + let lastScrollY = this.productsList.el?.scrollTop; + let ticking = false; + + const updateScrollDir = () => { + if (!this.productsList.el) { + return; + } + const scrollY = this.productsList.el?.scrollTop; + const amountScrolled = Math.abs(scrollY - lastScrollY); + if (amountScrolled < threshold) { + ticking = false; + return; + } + scrollingDown = scrollY > lastScrollY; + if (scrollingDown === this.privateState.navbarIsShown) { + this.toggleNavbar(this.privateState.navbarIsShown); + } + lastScrollY = scrollY > 0 ? scrollY : 0; + ticking = false; + }; + + const onScroll = () => { + if (!ticking) { + // this makes the scrolling smooth + window.requestAnimationFrame(updateScrollDir); + ticking = true; + } + }; + this.productsList.el.addEventListener("scroll", onScroll); + return () => this.productsList.el.removeEventListener("scroll", onScroll); + }, + () => [this.privateState.scrollingDown, this.privateState.searchIsFocused] + ); + // this IntersectionObserver is used to highlight the tag (in the header) + // of the category that is currently visible in the viewport + useEffect( + (searchIsFocused) => { + if (searchIsFocused) { + return; + } + const OBSERVING_WINDOW_HEIGHT = 5; + const observer = new IntersectionObserver( + (entries) => { + const entry = entries.filter((entry) => entry.isIntersecting)?.[0]; + if (entry) { + this.privateState.selectedTag = + entry.target.querySelector("h3").textContent; + // we scroll the tag list horizontally so that the selected tag is in the middle of the screen + this.tagList?.el?.scroll({ + top: 0, + left: + this.tagButtons[this.privateState.selectedTag].el.offsetLeft - + window.innerWidth / 2, + behavior: "smooth", + }); + } + }, + { + root: this.productsList.el, + rootMargin: `0px 0px -${ + this.productsList.el.offsetHeight - + parseInt(this.productsList.el.style.paddingBottom) - + OBSERVING_WINDOW_HEIGHT + }px 0px`, + } + ); + Object.keys(this.productGroup).forEach((tag) => { + observer.observe(this.productGroup[tag]?.el); + }); + return () => { + observer.disconnect(); + }; + }, + () => [this.privateState.searchIsFocused] + ); + } + /** + * This function hides or shows the navbar by sliding the whole productPage up or down + * @param {boolean} hide - true if the navbar should be hidden, false otherwise + */ + toggleNavbar(hide) { + const animationSteps = hide + ? [{ top: "0px" }, { top: `-${this.navbarHeight}px` }] + : [{ top: `-${this.navbarHeight}px` }, { top: "0px" }]; + this.productPage.el.animate(animationSteps, { + duration: 200, + fill: "forwards", + }); + this.privateState.navbarIsShown = !this.privateState.navbarIsShown; + } + + scrollToTop() { + this.productsList?.el?.scroll({ + top: 0, + left: 0, + behavior: "smooth", + }); + } + /** + * This function scrolls the productsList to the ref passed as argument, + * it takes into account the height of the header + * @param {Object} ref - the ref to scroll to + */ + scrollTo(ref) { + const y = ref?.el.offsetTop; + // the intersection observer will detect on which product category we are and + // it is possible that after scrolling we are a couple of pixels short of the desired category + // so it actually sees the previous category. To avoid this we add a small correction + const SCROLL_CORRECTION = 4; + const scrollOffset = this.headerHeight - SCROLL_CORRECTION; + this.productsList?.el?.scroll({ + top: y - scrollOffset, + behavior: "smooth", + }); + } + /** + * This function returns the list of products that should be displayed; + * it filters the products based on the search input + * @returns {Object} the list of products that should be displayed + */ + filteredProducts() { + if (!this.privateState.searchInput) { + return this.selfOrder.products; + } + return fuzzyLookup( + this.privateState.searchInput, + this.selfOrder.products, + (product) => product.name + product.description_sale + ); + } + /** + * This function is called when a tag is clicked; it selects the chosen tag and deselects all the other tags + * @param {string} tag_name + */ + selectTag(tag_name) { + if (this.privateState.selectedTag === tag_name) { + this.privateState.selectedTag = ""; + this.scrollToTop(); + return; + } + // When the user clicks on a tag, we scroll to the part of the page + // where the products with that tag are displayed. + // after the scrolling is done, the intersection observer will + // automatically set the privateState.selectedTag to the tag_name + this.scrollTo(this.productGroup[tag_name]); + } + /** + * This function is called when the search button is clicked. + * It sets the state so the search input is focused. + * It also deselects all the selected tags + */ + focusSearch() { + this.privateState.searchIsFocused = true; + if (this.privateState.navbarIsShown) { + this.toggleNavbar(true); + } + this.scrollToTop(); + } + /** + * This function is called when the search input 'x' button is clicked + */ + closeSearch() { + this.privateState.searchIsFocused = false; + this.privateState.searchInput = ""; + } + clickOnProduct(product) { + if (product.has_image || product.description_sale || product.attributes.length) { + this.env.navigate("/products/" + product.product_id); + } + } +} diff --git a/addons/pos_self_order/static/src/ProductList/ProductList.xml b/addons/pos_self_order/static/src/ProductList/ProductList.xml new file mode 100644 index 00000000000..aa35753236b --- /dev/null +++ b/addons/pos_self_order/static/src/ProductList/ProductList.xml @@ -0,0 +1,112 @@ + + + + +
+
+ +
+ + + +
+
+ +
+
+ + +
+
+
+
+ + +

+ No products found +

+
+ + + + + +
+

+ + + +

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

+

+

+ +

+

+ +
+ Product image +
+
+
+
+ +
+ +
+ +
+
+
+ + + + + + +
+ diff --git a/addons/pos_self_order/static/src/ProductMainView/ProductMainView.js b/addons/pos_self_order/static/src/ProductMainView/ProductMainView.js new file mode 100644 index 00000000000..5f5df48db73 --- /dev/null +++ b/addons/pos_self_order/static/src/ProductMainView/ProductMainView.js @@ -0,0 +1,21 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { useSelfOrder } from "@pos_self_order/SelfOrderService"; +import { formatMonetary } from "@web/views/fields/formatters"; +import { NavBar } from "@pos_self_order/NavBar/NavBar"; +export class ProductMainView extends Component { + static template = "pos_self_order.ProductMainView"; + static props = { product: Object }; + static components = { + NavBar, + }; + setup() { + // we want to keep track of the last product that was viewed + this.env.state.currentProduct = this.props.product.product_id; + this.selfOrder = useSelfOrder(); + this.formatMonetary = formatMonetary; + } +} + +export default { ProductMainView }; diff --git a/addons/pos_self_order/static/src/ProductMainView/ProductMainView.xml b/addons/pos_self_order/static/src/ProductMainView/ProductMainView.xml new file mode 100644 index 00000000000..680159213ab --- /dev/null +++ b/addons/pos_self_order/static/src/ProductMainView/ProductMainView.xml @@ -0,0 +1,90 @@ + + + +
+ +
+
+ + Product image + +
+

+ +

+

+ +

+
+
+ +
+ +
+
+ + +
+ + + + + +
+
+ + + +
+ +
+ + +
+
+ + + + +
+
+
+
+ + +
+ + +
+
+ + +
+
    +
  • + +
  • +
+
+
+ diff --git a/addons/pos_self_order/static/src/SelfOrderRoot.js b/addons/pos_self_order/static/src/SelfOrderRoot.js new file mode 100644 index 00000000000..55ed8a81be9 --- /dev/null +++ b/addons/pos_self_order/static/src/SelfOrderRoot.js @@ -0,0 +1,92 @@ +/** @odoo-module */ +import { Component, whenReady, App, useState, useSubEnv } from "@odoo/owl"; +import { makeEnv, startServices } from "@web/env"; +import { setLoadXmlDefaultApp, templates } from "@web/core/assets"; +import { _t } from "@web/core/l10n/translation"; +import { LandingPage } from "@pos_self_order/LandingPage/LandingPage"; +import { NavBar } from "@pos_self_order/NavBar/NavBar"; +import { ProductMainView } from "@pos_self_order/ProductMainView/ProductMainView"; +import { ProductList } from "@pos_self_order/ProductList/ProductList"; +import { useSelfOrder } from "@pos_self_order/SelfOrderService"; +import { Router } from "@pos_self_order/router"; +import { groupBy } from "@web/core/utils/arrays"; + +class SelfOrderRoot extends Component { + static template = "pos_self_order.SelfOrderRoot"; + static components = { + LandingPage, + ProductMainView, + NavBar, + ProductList, + Router, + }; + /* + This is the Root Component of the SelfOrder App + Most of the business logic is done here + The app has the folowing screens: + 0. LandingPage -- the main screen of the app + -- it has a button that redirects to the menu + 1. ProductList -- the screen that shows the list of products ( the menu ) + 2. ProductMainView -- the screen that shows the details of a product ( the product page ) + */ + setup() { + this.selfOrder = useSelfOrder(); + this.state = useState({ + currentProduct: 0, + }); + useSubEnv({ state: this.state }); + this.selfOrder.products = this.selfOrder.products.map( + ({ + id, + pos_categ_id, + price_info, + display_name, + description_sale, + attribute_line_ids, + ...rest + }) => ({ + product_id: id, + list_price: this.selfOrder.show_prices_with_tax_included + ? price_info["price_with_tax"] + : price_info["price_without_tax"], + tag: pos_categ_id ? pos_categ_id[1].split(" / ").at(-1) : "Other", + // the display name is like '[FURN_7888] Desk Stand with Screen' so we remove the [FURN_7888] part + name: display_name.replace(/^\[.*]/g, ""), + // we add the attributes to the products + attributes: getProductAttributes( + attribute_line_ids, + this.selfOrder.attributes_by_ptal_id + ), + description_sale: description_sale ? description_sale : "", + ...rest, + }) + ); + // we create a set with all the tags that are present in the menu + this.selfOrder.tagList = new Set(this.selfOrder.products.map((product) => product.tag)); + this.selfOrder.productListGroupedByTag = groupBy(this.selfOrder.products, "tag"); + } +} +export function getProductAttributes(attribute_line_ids, attributes_by_ptal_id) { + return attribute_line_ids.some((id) => id in attributes_by_ptal_id) + ? attribute_line_ids + .map((id) => attributes_by_ptal_id[id]) + .filter((attr) => attr !== undefined) + : []; +} + +export async function createPublicRoot() { + await whenReady(); + const env = makeEnv(); + await startServices(env); + const app = new App(SelfOrderRoot, { + templates, + env: env, + dev: env.debug, + translateFn: _t, + translatableAttributes: ["data-tooltip"], + }); + setLoadXmlDefaultApp(app); + return app.mount(document.body); +} +createPublicRoot(); +export default { SelfOrderRoot, createPublicRoot }; diff --git a/addons/pos_self_order/static/src/SelfOrderRoot.xml b/addons/pos_self_order/static/src/SelfOrderRoot.xml new file mode 100644 index 00000000000..6e514ef0133 --- /dev/null +++ b/addons/pos_self_order/static/src/SelfOrderRoot.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + diff --git a/addons/pos_self_order/static/src/SelfOrderService.js b/addons/pos_self_order/static/src/SelfOrderService.js new file mode 100644 index 00000000000..6f9bbc89a04 --- /dev/null +++ b/addons/pos_self_order/static/src/SelfOrderService.js @@ -0,0 +1,20 @@ +/** @odoo-module */ +import { useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { session } from "@web/session"; + +const selfOrderService = { + dependencies: ["rpc"], + async start(env, { rpc }) { + const object = { + ...session.pos_self_order, + }; + return object; + }, +}; +registry.category("services").add("self_order", selfOrderService); + +export function useSelfOrder() { + return useState(useService("self_order")); +} diff --git a/addons/pos_self_order/static/src/pos_self_order_styles.scss b/addons/pos_self_order/static/src/pos_self_order_styles.scss new file mode 100644 index 00000000000..0d74c378d01 --- /dev/null +++ b/addons/pos_self_order/static/src/pos_self_order_styles.scss @@ -0,0 +1,50 @@ +html, body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + overflow: hidden; + position: fixed; +} + +.break-line { + box-sizing: border-box; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.o_self_order_item_card--image { + width:110px; + min-width: 110px; + min-height: 110px; + + & img { + object-fit: cover; + } +} +.o_self_order_item_card { + border-right: 1px solid $border-color; + border-bottom: 1px solid $border-color; + + @include media-breakpoint-down(md) { + &:nth-child(1n) { + border-right: 0; + } + } + @include media-breakpoint-up(md) { + &:nth-child(2n+1) { + border-right: 0; + } + } + @include media-breakpoint-up(xl) { + &:nth-child(2n+1) { + border-right: 1px solid $border-color; + } + + &:nth-child(4n+1) { + border-right: 0; + } + } +} diff --git a/addons/pos_self_order/static/src/router.js b/addons/pos_self_order/static/src/router.js new file mode 100644 index 00000000000..4fbec50042a --- /dev/null +++ b/addons/pos_self_order/static/src/router.js @@ -0,0 +1,86 @@ +/** @odoo-module */ + +import { Component, useState, useSubEnv, xml } from "@odoo/owl"; +import { escapeRegExp } from "@web/core/utils/strings"; +import { browser } from "@web/core/browser/browser"; +import { zip } from "@web/core/utils/arrays"; + +function parseParams(matches, paramSpecs) { + return Object.fromEntries( + zip(matches, paramSpecs).map(([match, paramSpec]) => { + const { type, name } = paramSpec; + switch (type) { + case "int": + return [name, parseInt(match)]; + case "string": + return [name, match]; + default: + throw new Error(`Unknown type ${type}`); + } + }) + ); +} +export class Router extends Component { + static template = xml``; + setup() { + useSubEnv({ navigate: this.navigate.bind(this) }); + this.state = useState({ + activeSlot: "default", + slotProps: {}, + }); + // this is needed for the back button to work + window.addEventListener("popstate", (event) => { + this.matchURL(); + }); + this.routes = Object.keys(this.props.slots).map((route) => { + const paramStrings = route.match(/\{\w+:\w+\}/g); + if (!paramStrings) { + return { route, paramSpecs: [], regex: new RegExp(`^${route}$`) }; + } + const paramSpecs = paramStrings.map((paramString) => { + const [, type, name] = paramString.match(/(\w+):(\w+)/); + return { type, name }; + }); + const regex = new RegExp( + `^${route + .split(/\{\w+:\w+\}/) + .map((part) => escapeRegExp(part)) + .join("([^/]+)")}$` + ); + return { route, paramSpecs, regex }; + }); + this.matchURL(); + } + + matchURL() { + const path = browser.location.pathname; + for (const { route, paramSpecs, regex } of this.routes) { + const match = path.match(regex); + if (match) { + const parsedParams = parseParams(match.slice(1), paramSpecs); + this.state.activeSlot = route; + this.state.slotProps = parsedParams; + return; + } + } + this.state.activeSlot = "default"; + this.state.slotProps = {}; + } + /** + * Navigate to the given relative route. + * We use the history API to navigate to it. + * (this means that we don't make aditional requests to the server) + * @param {string} route + * @param {Event} event + */ + navigate(route, event = null) { + if (!route.startsWith("/")) { + return; + } + event?.preventDefault(); + const url = new URL(browser.location.href); + url.pathname = `menu/${this.props.pos_config_id}${route}`; + history.pushState({}, "", url); + this.matchURL(); + } +} diff --git a/addons/pos_self_order/static/tests/tours/self_order_tour.js b/addons/pos_self_order/static/tests/tours/self_order_tour.js new file mode 100644 index 00000000000..c4bb3722679 --- /dev/null +++ b/addons/pos_self_order/static/tests/tours/self_order_tour.js @@ -0,0 +1,139 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; + +registry.category("web_tour.tours").add("pos_self_order_tour", { + test: true, + steps: [ + { + content: "Test that the default View Menu button is present", + trigger: ".btn", + }, + { + content: "Test that products are present", + trigger: ".o_self_order_item_card", + isCheck: true, + }, + { + content: "Test that the 'No products found' message is not present", + trigger: "body:not(:has(p:contains('No products found')))", + isCheck: true, + }, + // We should be on the products screen now + { + content: "Test that the tag list is present", + trigger: ".o_self_order_searchbar_filter li", + isCheck: true, + }, + { + content: "Test that the first tag is active", + trigger: ".o_self_order_searchbar_filter li:nth-child(1) span.active", + isCheck: true, + }, + { + content: "Test that the search icon is present", + trigger: ".oi.oi-search", + }, + { + content: "Test that the search icon is not present after clicking on it", + trigger: "body:not(:has(.oi.oi-search))", + isCheck: true, + }, + { + content: "Test that the tag list is not present after clicking on the search icon", + trigger: "body:not(:has(.o_search_bar_filter li))", + isCheck: true, + }, + { + content: "Test that the search box is visible after clicking on the search icon", + trigger: "input[placeholder='Pizza']", + run: "text Desk", + }, + { + content: "Test that the search results are present", + trigger: ".o_self_order_item_card", + isCheck: true, + }, + { + content: "Test that the 'No products found' message is not present", + trigger: "body:not(:has(p:contains('No products found')))", + isCheck: true, + }, + { + trigger: "input", + run: "text xyzxyzxyzxyxyxyxyxyxzxyxyxyxyxyxyxyxyyxzyxzyxzyxyzxyzyx", + }, + { + content: "Test that no products are present", + trigger: "body:not(:has(.o_self_order_item_card))", + isCheck: true, + }, + { + content: "Test that the 'No products found' message is present", + trigger: "p:contains('No products found')", + isCheck: true, + }, + { + content: "Test that the button to exit the search box is present and click on it", + trigger: ".fa.fa-times", + }, + { + content: "Test that the search icon is present again", + trigger: ".oi.oi-search", + isCheck: true, + }, + { + content: "Test that the tag list is present and click on the 2nd tag", + trigger: ".o_self_order_searchbar_filter li:nth-child(2)", + }, + { + content: "Test that the tag is highlighted and click on it to deactivate it", + trigger: ".o_self_order_searchbar_filter li:nth-child(2) span.active", + }, + { + content: "Test that the tag is not highlighted anymore", + trigger: ".o_self_order_searchbar_filter li:nth-child(2)", + isCheck: true, + }, + // After clicking on the 2nd tag, it will be deactivated and the screen should scroll up to the top + // when we reach the top of the screen, the intersection observer shoould trigger and select the 1st tag + // we check that the 1st tag is now active <-- this would mean that the intersection observer is working properly + { + content: "Test that the first tag is active", + trigger: ".o_self_order_searchbar_filter li:nth-child(1) span.active", + isCheck: true, + }, + { + content: "Test that the product description is present and click on it", + trigger: "p.text-muted.small.break-line", + }, + // We should now be on the product screen + { + content: "Test that the product name is present in the product screen", + trigger: "div.d-flex.flex-column.py-3 > h3", + isCheck: true, + }, + { + content: "Test that the back button is present on the product screen and click on it", + trigger: "nav.o_self_order_navbar > button", + }, + // now that we are back on the product list screen, we click the back button again to go to the landing page + { + content: + "Test that the back button is present on the product list screen and click on it", + trigger: "nav.o_self_order_navbar > button", + }, + // on the landing page, we look for the View Menu button + // finding it also means that the back button works properly + { + content: "Test that the default View Menu button is present", + trigger: ".btn", + isCheck: true, + }, + { + content: "Test that the back button is not present on the landing page", + trigger: "body:not(:has(nav.o_self_order_navbar > button))", + isCheck: true, + }, + ], +}); diff --git a/addons/pos_self_order/tests/__init__.py b/addons/pos_self_order/tests/__init__.py new file mode 100644 index 00000000000..a873cd0dbf3 --- /dev/null +++ b/addons/pos_self_order/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_controllers +from . import test_frontend diff --git a/addons/pos_self_order/tests/test_controllers.py b/addons/pos_self_order/tests/test_controllers.py new file mode 100644 index 00000000000..65df0a776e7 --- /dev/null +++ b/addons/pos_self_order/tests/test_controllers.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo +from odoo.tests import HttpCase + +@odoo.tests.tagged("post_install", "-at_install") +class TestSelfOrderControllers(HttpCase): + def test_menu_redirect(self): + response = self.url_open("/menu") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.history[0].status_code, 303) + chosen_pos_config_id = int(response.url.split("/")[-1]) + available_pos_config_ids = ( + self.env["pos.config"].search([("self_order_view_mode", "=", True)]).ids + ) + self.assertTrue(chosen_pos_config_id in available_pos_config_ids) + + self.env["pos.config"].search([]).write({"self_order_view_mode": False}) + response = self.url_open("/menu") + self.assertEqual(response.status_code, 404) diff --git a/addons/pos_self_order/tests/test_frontend.py b/addons/pos_self_order/tests/test_frontend.py new file mode 100644 index 00000000000..4e5a66157f6 --- /dev/null +++ b/addons/pos_self_order/tests/test_frontend.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo.tests + + +@odoo.tests.tagged("post_install", "-at_install") +class TestFrontend(odoo.tests.HttpCase): + def setUp(self): + super().setUp() + self.pos_config = self.env["pos.config"].create( + { + "name": "Bar", + "module_pos_restaurant": True, + "is_table_management": True, + "iface_splitbill": True, + "iface_printbill": True, + "iface_orderline_notes": True, + "start_category": True, + "self_order_view_mode": True, + } + ) + basic_product = { + "name": "Test Product", + "type": "product", + "available_in_pos": True, + "list_price": 10, + "taxes_id": False, + } + self.env["product.product"].create([basic_product for i in range(1000)]) + + def test_self_order_tour(self): + self.start_tour( + f"/menu/{self.pos_config.id}", + "pos_self_order_tour", + login=None, + ) diff --git a/addons/pos_self_order/views/custom_link_views.xml b/addons/pos_self_order/views/custom_link_views.xml new file mode 100644 index 00000000000..be6e562b142 --- /dev/null +++ b/addons/pos_self_order/views/custom_link_views.xml @@ -0,0 +1,24 @@ + + + + + Custom Links + pos_self_order.custom_link + tree + + + custom.link.tree + pos_self_order.custom_link + + + + + + + + + + + + + diff --git a/addons/pos_self_order/views/index.xml b/addons/pos_self_order/views/index.xml new file mode 100644 index 00000000000..926f126cc32 --- /dev/null +++ b/addons/pos_self_order/views/index.xml @@ -0,0 +1,26 @@ + + + + diff --git a/addons/pos_self_order/views/point_of_sale_dashboard.xml b/addons/pos_self_order/views/point_of_sale_dashboard.xml new file mode 100644 index 00000000000..ecfebf8a483 --- /dev/null +++ b/addons/pos_self_order/views/point_of_sale_dashboard.xml @@ -0,0 +1,16 @@ + + + + pos.config.kanban.view.inherit.self_order + pos.config + + + + + + + + + diff --git a/addons/pos_self_order/views/qr_code.xml b/addons/pos_self_order/views/qr_code.xml new file mode 100644 index 00000000000..8e58fea68fd --- /dev/null +++ b/addons/pos_self_order/views/qr_code.xml @@ -0,0 +1,71 @@ + + + + + qr.codes + pos.config + qweb-pdf + pos_self_order.qr_codes_page + pos_self_order.qr_codes_page + "QR codes" + + report + + + QR Codes Page + + + + + + + + + + diff --git a/addons/pos_self_order/views/res_config_settings_views.xml b/addons/pos_self_order/views/res_config_settings_views.xml new file mode 100644 index 00000000000..60109966bc5 --- /dev/null +++ b/addons/pos_self_order/views/res_config_settings_views.xml @@ -0,0 +1,29 @@ + + + + res.config.settings.view.form.inherit.pos_self_order.view + res.config.settings + + + + + +
+
+
+
+
+ +
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/utils/hooks.js b/addons/web/static/src/core/utils/hooks.js index 08678dd1a90..aeade041b6c 100644 --- a/addons/web/static/src/core/utils/hooks.js +++ b/addons/web/static/src/core/utils/hooks.js @@ -34,13 +34,14 @@ import { status, useComponent, useEffect, useRef, onWillUnmount } from "@odoo/ow * @param {Object} [params] * @param {string} [params.refName] override the ref name "autofocus" * @param {boolean} [params.selectAll] if true, will select the entire text value. + * @param {boolean} [params.mobile] if true, will autofocus on mobile devices. * @returns {Ref} the element reference */ -export function useAutofocus({ refName, selectAll } = {}) { +export function useAutofocus({ refName, selectAll, mobile } = {}) { const comp = useComponent(); const ref = useRef(refName || "autofocus"); // Prevent autofocus in mobile - if (comp.env.isSmall) { + if (!mobile && comp.env.isSmall) { return ref; } // LEGACY diff --git a/addons/web/static/tests/core/utils/hooks_tests.js b/addons/web/static/tests/core/utils/hooks_tests.js index 4515fcf14b6..ea5fec64e8b 100644 --- a/addons/web/static/tests/core/utils/hooks_tests.js +++ b/addons/web/static/tests/core/utils/hooks_tests.js @@ -153,6 +153,77 @@ QUnit.module("utils", () => { await mount(MyComponent, target, { env }); }); + QUnit.test( + "useAutofocus works when isSmall and you provide mobile param", + async function (assert) { + class MyComponent extends Component { + setup() { + this.inputRef = useAutofocus({ mobile: true }); + } + } + MyComponent.template = xml` + + + + `; + + const fakeUIService = { + start(env) { + const ui = {}; + Object.defineProperty(env, "isSmall", { + get() { + return true; + }, + }); + + return ui; + }, + }; + + registry.category("services").add("ui", fakeUIService); + + const env = await makeTestEnv(); + const target = getFixture(); + const comp = await mount(MyComponent, target, { env }); + assert.strictEqual(document.activeElement, comp.inputRef.el); + } + ); + QUnit.test( + "useAutofocus does not focus when isSmall and you don't provide mobile param", + async function (assert) { + class MyComponent extends Component { + setup() { + this.inputRef = useAutofocus(); + } + } + MyComponent.template = xml` + + + + `; + + const fakeUIService = { + start(env) { + const ui = {}; + Object.defineProperty(env, "isSmall", { + get() { + return true; + }, + }); + + return ui; + }, + }; + + registry.category("services").add("ui", fakeUIService); + + const env = await makeTestEnv(); + const target = getFixture(); + const comp = await mount(MyComponent, target, { env }); + assert.notEqual(document.activeElement, comp.inputRef.el); + } + ); + QUnit.test("supports different ref names", async (assert) => { class MyComponent extends Component { setup() {