[REF] point_of_sale: move customer display logic to service

Previously, the customer display logic was split among multiple places:
the customer display button, the hardwareProxy service and the pos
global state. This commit extracts most of the logic to a new service
instead, and encapsulates the behaviour of a local display (popup
window) and a remote display (through an iot box) in separate classes.

Part-of: odoo/odoo#117290
This commit is contained in:
Samuel Degueldre
2023-04-06 16:12:02 +02:00
parent b9b10d5614
commit 45d08db3c4
7 changed files with 218 additions and 222 deletions
@@ -0,0 +1,146 @@
/** @odoo-module */
import { effect, Reactive } from "@point_of_sale/utils";
import { registry } from "@web/core/registry";
import { pick } from "@web/core/utils/objects";
export class LocalDisplay extends Reactive {
status = "success";
constructor() {
super();
this.setup(...arguments);
}
setup(globalState) {
this.globalState = globalState;
}
async connect() {
this.popupWindow = window.open("", "Customer Display", "height=600,width=900");
this.update({ refreshResources: true });
}
async update({ refreshResources = false } = {}) {
if (!this.popupWindow || this.popupWindow.closed) {
return;
}
// TODO: this could probably be improved by loading owl in the popup window,
// making it render the customer display, and simply sending messages to
// update the state, instead of sending HTML which causes jankiness with
// animations.
const { body: displayBody, head: displayHead } = this.popupWindow.document;
const container = document.createElement("div");
container.innerHTML = await this.globalState.customerDisplayHTML();
if (refreshResources) {
displayHead.textContent = "";
displayHead.appendChild(container.querySelector(".resources"));
}
displayBody.textContent = "";
displayBody.appendChild(container.querySelector(".pos-customer_facing_display"));
const orderLines = displayBody.querySelector(".pos_orderlines_list");
orderLines.scrollTop = orderLines.scrollHeight;
}
}
export class RemoteDisplay extends Reactive {
static serviceDependencies = ["hardware_proxy"];
status = "failure";
constructor() {
super();
this.setup(...arguments);
}
setup(globalState, { hardware_proxy }) {
this.hardwareProxy = hardware_proxy;
this.globalState = globalState;
this.updateStatus();
}
async connect() {
const html = await this.globalState.customerDisplayHTML();
try {
const { status } = await this.hardwareProxy.message("take_control", { html });
this.status = status === "success" ? "success" : "warning";
this.updateStatus();
} catch (error) {
this.status = error === undefined ? "failure" : "not_found";
}
}
async update() {
const html = await this.globalState.customerDisplayHTML();
if (this.isUpdatingStatus && this.hardwareProxy.connectionInfo.status === "connected") {
return this.hardwareProxy.message("customer_facing_display", { html });
}
}
async updateStatus() {
if (!this.hardwareProxy.host || this.isUpdatingStatus) {
return;
}
this.isUpdatingStatus = true;
while (this.isUpdatingStatus) {
try {
const { status } = await this.hardwareProxy.message("test_ownership");
this.status = status === "OWNER" ? "success" : "warning";
} catch (error) {
if (error === undefined) {
// FIXME POSREF when does this happen?
this.status = "failure";
} else {
this.status = "not_found";
this.isUpdatingStatus = false;
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 3000));
}
}
}
export const customerDisplayService = {
get dependencies() {
// getter so thet the RemoteDisplay dependencies are resolved when services start
// and not at module definition time, this allows patches to be accounted for
return ["pos", ...RemoteDisplay.serviceDependencies];
},
start(env, deps) {
const { globalState } = deps.pos;
// Dummy service, does nothing until the globalState is ready, then the methods get replaced
const service = {
connect() {},
};
globalState.ready.then(() => {
const {
iface_customer_facing_display: enabled,
iface_customer_facing_display_local: local,
iface_customer_facing_display_via_proxy: proxy,
} = globalState.config;
if (!enabled) {
return;
}
let display;
if (local) {
display = new LocalDisplay(globalState);
} else if (proxy) {
display = new RemoteDisplay(
globalState,
pick(deps, ...RemoteDisplay.serviceDependencies)
);
} else {
// FIXME POSREF can this ever happen?
return;
}
// FIXME POSREF this is very inelegant. There are a lot of places that get more complicated
// because we want to mount the chrome before the globalState is loaded. Maybe we should
// just make the loading screen its own thing that doesn't depend on anything, instead
// of having it be a part of the chrome and having to propagate complexity everywhere.
env.services.customer_display = display;
// Register an effect to update the display automatically when anything it renders changes
effect((display) => display.update(), [display]);
});
return service;
},
};
registry.category("services").add("customer_display", customerDisplayService);
@@ -23,7 +23,6 @@ export class HardwareProxy extends EventBus {
this.useDebugWeight = false;
this.host = "";
this.keptalive = false;
this.customerDisplayAvailable = true;
this.connectionInfo = reactive({ status: "disconnected", drivers: {} });
effect(
(info) => {
@@ -191,27 +190,6 @@ export class HardwareProxy extends EventBus {
this.debugWeight = 0;
}
updateCustomerDisplay(html) {
if (this.customerDisplayAvailable && this.connectionInfo.status == "connected") {
return this.message("customer_facing_display", { html }, { timeout: 5000 });
}
}
/**
* @param {string} html
* @returns {Promise}
*/
takeControlOfCustomerDisplay(html) {
return this.message("take_control", { html });
}
/**
* @returns {Promise}
*/
testOwnershipOfCustomerDisplay() {
return this.host ? this.message("test_ownership") : Promise.reject({ abort: true });
}
// asks the proxy to log some information, as with the debug.log you can provide several arguments.
log() {
return this.message("log", { arguments: [...arguments] });
@@ -2,101 +2,21 @@
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { usePos } from "@point_of_sale/app/pos_hook";
import { _t } from "@web/core/l10n/translation";
export class CustomerFacingDisplayButton extends Component {
static template = "CustomerFacingDisplayButton";
setup() {
super.setup();
this.hardwareProxy = useService("hardware_proxy");
this.local =
this.env.pos.config.iface_customer_facing_display_local &&
!this.env.pos.config.iface_customer_facing_display_via_proxy;
this.state = useState({ status: this.local ? "success" : "failure" });
this._start();
this.pos = usePos();
this.customerDisplay = useState(useService("customer_display"));
}
get message() {
return {
success: "",
warning: this.env._t("Connected, Not Owned"),
failure: this.env._t("Disconnected"),
not_found: this.env._t("Customer Screen Unsupported. Please upgrade the IoT Box"),
}[this.state.status];
}
onClick() {
if (this.local) {
return this.onClickLocal();
} else {
return this.onClickProxy();
}
}
async onClickLocal() {
this.env.pos.customer_display = window.open("", "Customer Display", "height=600,width=900");
const renderedHtml = await this.env.pos.render_html_for_customer_facing_display();
var $renderedHtml = $("<div>").html(renderedHtml);
$(this.env.pos.customer_display.document.body).html(
$renderedHtml.find(".pos-customer_facing_display")
);
$(this.env.pos.customer_display.document.head).html(
$renderedHtml.find(".resources").html()
);
}
async onClickProxy() {
try {
const renderedHtml = await this.env.pos.render_html_for_customer_facing_display();
let ownership = await this.hardwareProxy.takeControlOfCustomerDisplay(renderedHtml);
if (typeof ownership === "string") {
ownership = JSON.parse(ownership);
}
if (ownership.status === "success") {
this.state.status = "success";
} else {
this.state.status = "warning";
}
if (!this.hardwareProxy.customerDisplayAvailable) {
this.hardwareProxy.customerDisplayAvailable = true;
this._start();
}
} catch (error) {
if (typeof error == "undefined") {
this.state.status = "failure";
} else {
this.state.status = "not_found";
}
}
}
_start() {
if (this.local) {
return;
}
const loop = async () => {
if (this.hardwareProxy.customerDisplayAvailable) {
try {
let ownership = await this.hardwareProxy.testOwnershipOfCustomerDisplay();
if (typeof ownership === "string") {
ownership = JSON.parse(ownership);
}
if (ownership.status === "OWNER") {
this.state.status = "success";
} else {
this.state.status = "warning";
}
} catch (error) {
if (error.abort) {
// Stop the loop
return;
}
if (typeof error == "undefined") {
this.state.status = "failure";
} else {
this.state.status = "not_found";
this.hardwareProxy.customerDisplayAvailable = false;
}
}
setTimeout(loop, 3000);
}
};
loop();
warning: _t("Connected, Not Owned"),
failure: _t("Disconnected"),
not_found: _t("Customer Screen Unsupported. Please upgrade the IoT Box"),
}[this.customerDisplay.status];
}
}
+51 -101
View File
@@ -15,17 +15,41 @@ import { markRaw, reactive } from "@odoo/owl";
import { ConfirmPopup } from "@point_of_sale/js/Popups/ConfirmPopup";
import { escape } from "@web/core/utils/strings";
import { Mutex } from "@web/core/utils/concurrency";
import { memoize } from "@web/core/utils/functions";
import { _t } from "@web/core/l10n/translation";
import { renderToString } from "@web/core/utils/render";
var QWeb = core.qweb;
var _t = core._t;
var round_di = utils.round_decimals;
var round_pr = utils.round_precision;
const Markup = utils.Markup;
// Container of the product images fetched during rendering
// of customer display. There is no need to observe it, thus,
// we are putting it outside of PosGlobalState.
const PRODUCT_ID_TO_IMAGE_CACHE = {};
/**
* Gets a product image as a base64 string so that it can be sent to the
* customer display, as the display won't be able to fetch it, since the image
* controller requires the client to be logged. This function is memoized on the
* product id, so that we will only do this once per product.
*
* @param {number} productId id of the product
* @param {string} writeDate the write date of the product, used as a cache
* buster in case the product image has been changed
* @returns {string} the base64 representation of the product's image
*/
const getProductImage = memoize(function getProductImage(productId, writeDate) {
return new Promise(function (resolve, reject) {
const img = new Image();
img.addEventListener("load", () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.height = img.height;
canvas.width = img.width;
ctx.drawImage(img, 0, 0);
resolve(canvas.toDataURL("image/jpeg"));
});
img.addEventListener("error", reject);
img.src = `/web/image?model=product.product&field=image_128&id=${productId}&unique=${writeDate}`;
});
});
/**
* If optimization is needed, then we should implement this
@@ -919,105 +943,31 @@ export class PosGlobalState extends PosModel {
return this.orders;
}
_convert_product_img_to_base64(product, url) {
return new Promise(function (resolve, reject) {
var img = new Image();
img.onload = function () {
var canvas = document.createElement("CANVAS");
var ctx = canvas.getContext("2d");
canvas.height = this.height;
canvas.width = this.width;
ctx.drawImage(this, 0, 0);
var dataURL = canvas.toDataURL("image/jpeg");
canvas = null;
resolve([product, dataURL]);
};
img.crossOrigin = "use-credentials";
img.src = url;
});
}
get customer_display() {
return this.unwatched.customer_display;
}
set customer_display(value) {
this.unwatched.customer_display = markRaw(value);
}
send_current_order_to_customer_facing_display() {
var self = this;
if (!this.config.iface_customer_facing_display) {
/**
* Renders the HTML for the customer display and returns it as a string.
*
* @returns {string}
*/
async customerDisplayHTML() {
const order = this.get_order();
if (!order) {
return;
}
this.render_html_for_customer_facing_display().then((rendered_html) => {
if (self.env.pos.customer_display) {
var $renderedHtml = $("<div>").html(rendered_html);
$(self.env.pos.customer_display.document.body).html(
$renderedHtml.find(".pos-customer_facing_display")
);
var orderlines = $(self.env.pos.customer_display.document.body).find(
".pos_orderlines_list"
);
orderlines.scrollTop(orderlines.prop("scrollHeight"));
} else if (
this.config.iface_customer_facing_display_via_proxy &&
this.hardwareProxy.customerDisplayAvailable
) {
this.hardwareProxy.updateCustomerDisplay(rendered_html);
}
});
}
const orderLines = order.get_orderlines();
const productImages = Object.fromEntries(
await Promise.all(
orderLines.map(async ({ product }) => [
product.id,
await getProductImage(product.id, product.writeDate),
])
)
);
/**
* @returns {Promise<string>}
*/
render_html_for_customer_facing_display() {
var self = this;
var order = this.get_order();
// If we're using an external device like the IoT Box, we
// cannot get /web/image?model=product.product because the
// IoT Box is not logged in and thus doesn't have the access
// rights to access product.product. So instead we'll base64
// encode it and embed it in the HTML.
var get_image_promises = [];
if (order) {
order.get_orderlines().forEach(function (orderline) {
var product = orderline.product;
var image_url = `/web/image?model=product.product&field=image_128&id=${product.id}&unique=${product.write_date}`;
// only download and convert image if we haven't done it before
if (!(product.id in PRODUCT_ID_TO_IMAGE_CACHE)) {
get_image_promises.push(
self._convert_product_img_to_base64(product, image_url)
);
}
});
}
return Promise.all(get_image_promises).then(function (productIdImagePairs) {
for (const [product, image] of productIdImagePairs) {
PRODUCT_ID_TO_IMAGE_CACHE[product.id] = image;
}
// Collect the product images that will be used in rendering the customer display template.
const productImages = {};
if (order) {
for (const line of order.get_orderlines()) {
productImages[line.product.id] = PRODUCT_ID_TO_IMAGE_CACHE[line.product.id];
}
}
return QWeb.render("CustomerFacingDisplayOrder", {
pos: self,
origin: window.location.origin,
order: order,
productImages,
});
return renderToString("CustomerFacingDisplayOrder", {
pos: this,
origin: window.location.origin,
order,
productImages,
});
}
@@ -220,6 +220,7 @@ td {
height: 0px;
}
.pos .subwindow .subwindow-container{
height: 100%;
display: table-cell;
position: relative;
}
@@ -1440,6 +1441,7 @@ td {
height:100%;
}
.pos .screen .content-cell{
height: 100%;
width:100%;
}
.pos .screen .content-cell .content-container{
@@ -2,15 +2,15 @@
<templates id="template" xml:space="preserve">
<t t-name="CustomerFacingDisplayButton" owl="1">
<div class="oe_status" t-on-click="onClick">
<div class="oe_status" t-on-click="() => customerDisplay.connect()">
<span class="message"><t t-esc="message" /></span>
<div t-if="state.status === 'warning'" class="js_warning oe_icon oe_orange">
<div t-if="customerDisplay.status === 'warning'" class="js_warning oe_icon oe_orange">
<i class="fa fa-fw fa-desktop" role="img" aria-label="Customer Screen Warning" title="Customer Screen Warning"></i>
</div>
<div t-if="state.status === 'failure'" class="js_disconnected oe_icon oe_red">
<div t-if="customerDisplay.status === 'failure'" class="js_disconnected oe_icon oe_red">
<i class="fa fa-fw fa-desktop" role="img" aria-label="Customer Screen Disconnected" title="Customer Screen Disconnected"></i>
</div>
<div t-if="state.status === 'success'" class="js_connected oe_icon oe_green">
<div t-if="customerDisplay.status === 'success'" class="js_connected oe_icon oe_green">
<i class="fa fa-fw fa-desktop" role="img" aria-label="Customer Screen Connected" title="Customer Screen Connected"></i>
</div>
</div>
@@ -1,18 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="CustomerFacingDisplayHead">
<t t-name="CustomerFacingDisplayHead" owl="1">
<div class="resources">
<base t-att-href="origin"/>
<meta http-equiv="cache-control" content="no-cache" />
<meta http-equiv="pragma" content="no-cache" />
<link rel="stylesheet" type="text/css" href="/web/static/lib/bootstrap/css/bootstrap.css"/>
<link rel="stylesheet" type="text/css" href="/web/static/lib/bootstrap/dist/css/bootstrap.css"/>
<link rel="stylesheet" type="text/css" href="/web/static/src/libs/fontawesome/css/font-awesome.css"/>
<link rel="stylesheet" type="text/css" href="/point_of_sale/static/src/css/customer_facing_display.css"/>
</div>
</t>
<t t-name="CustomerFacingDisplayOrder">
<t t-name="CustomerFacingDisplayOrder" owl="1">
<!-- Header -->
<t t-call="CustomerFacingDisplayHead"/>
@@ -49,7 +49,7 @@
</div>
</t>
<t t-name="CustomerFacingDisplayOrderLines">
<t t-name="CustomerFacingDisplayOrderLines" owl="1">
<div class="pos_orderlines">
<div class="pos_orderlines_item pos_orderlines_header">
<div/>
@@ -59,7 +59,7 @@
</div>
<div class="pos_orderlines_list">
<t t-if="order">
<div t-foreach="order.get_orderlines()" t-as="orderline" class="pos_orderlines_item">
<div t-foreach="order.get_orderlines()" t-as="orderline" t-key="orderline_index" class="pos_orderlines_item">
<div><div t-attf-style="background-image:url(#{productImages[orderline.product.id]})"/></div>
<div t-esc="orderline.get_full_product_name()"/>
<div t-esc="orderline.get_quantity_str()"/>
@@ -70,10 +70,10 @@
</div>
</t>
<t t-name="CustomerFacingDisplayPaymentLines">
<t t-name="CustomerFacingDisplayPaymentLines" owl="1">
<div class="pos-paymentlines">
<t t-if="order">
<t t-foreach="order.get_paymentlines()" t-as="paymentline">
<t t-foreach="order.get_paymentlines()" t-as="paymentline" t-key="paymentline_index">
<div>
<span><t t-esc="paymentline.name"/>:</span>
</div>