[REF] pos*, web: make pos service start asynchronously

*: point_of_sale, pos_hr, pos_restaurant

Previously, the pos service started synchronously, but doing anything
meaningful with it needed to be done after it was "ready", meaning it
had loaded and processed the data. The reason for this is that we need
to start the services before we mount the chrome, but in the pos we want
to show the loader immediately while the data is loading. This means
that any service that depends on the pos service in a meaningful manner
has to be written in a convoluted way, where it starts as a dummy
service and then overwrites itself in the env when it's actually ready.

This commit allows to write services that depend on the pos service more
naturally, by making the pos service properly asynchronous, meaning its
dependents will only be loaded once it's actually ready. To work around
the loader issue, the loader is mounted as a separate owl appplication
with not services, this application is shown over the chrome and when
the chrome mounts, it hides the loader and destroys the loader
application after the fade-out transition.

closes odoo/odoo#119908

Signed-off-by: Joseph Caburnay (jcb) <jcb@odoo.com>
This commit is contained in:
Samuel Degueldre
2023-04-28 13:33:15 +02:00
parent f18bdad78a
commit d11bf24dea
20 changed files with 283 additions and 338 deletions
+13 -10
View File
@@ -3,18 +3,21 @@
import { Chrome } from "@point_of_sale/js/Chrome";
import { patch } from "@web/core/utils/patch";
import { ClosePosPopup } from "@point_of_sale/js/Popups/ClosePosPopup";
import { onMounted } from "@odoo/owl";
patch(Chrome.prototype, "l10n_fr_pos_cert.Chrome", {
async start() {
await this._super(...arguments);
if (this.env.pos.is_french_country() && this.env.pos.pos_session.start_at) {
const now = Date.now();
const limitDate = new Date(this.env.pos.pos_session.start_at);
limitDate.setDate(limitDate.getDate() + 1);
if (limitDate.getTime() < now) {
const info = await this.env.pos.getClosePosInfo();
this.popup.add(ClosePosPopup, { info });
setup() {
this._super(...arguments);
onMounted(async () => {
if (this.env.pos.is_french_country() && this.env.pos.pos_session.start_at) {
const now = Date.now();
const limitDate = new Date(this.env.pos.pos_session.start_at);
limitDate.setDate(limitDate.getDate() + 1);
if (limitDate.getTime() < now) {
const info = await this.env.pos.getClosePosInfo();
this.popup.add(ClosePosPopup, { info });
}
}
}
});
},
});
@@ -104,46 +104,23 @@ export const customerDisplayService = {
start(env, deps) {
const { globalState } = deps.pos;
// Dummy service, does nothing until the globalState is ready, then the methods get replaced
const service = {
connect() {},
};
const {
iface_customer_facing_display: enabled,
iface_customer_facing_display_via_proxy: proxy,
} = globalState.config;
if (!enabled) {
return;
}
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(
batched((display) => display.update()),
[display]
);
});
return service;
const display = proxy
? new RemoteDisplay(globalState, pick(deps, ...RemoteDisplay.serviceDependencies))
: new LocalDisplay(globalState);
// Register an effect to update the display automatically when anything it renders changes
effect(
batched((display) => display.update()),
[display]
);
return display;
},
};
@@ -6,17 +6,14 @@ import { DebugWidget } from "./debug_widget";
import { withComputedProperties } from "@point_of_sale/utils";
const debugService = {
dependencies: ["pos"],
/**
* @param {{ debug: string }} env
* @param {object} deps
* @param {import("@point_of_sale/app/pos_store").PosStore} deps.pos
*/
start(env, { pos }) {
start(env) {
const internalState = reactive({ widgetOpen: true });
const state = withComputedProperties(reactive({}), [internalState, pos], {
showWidget(internalState, pos) {
return env.debug && pos.uiState === "READY" && internalState.widgetOpen;
const state = withComputedProperties(reactive({}), [internalState], {
showWidget(internalState) {
return env.debug && internalState.widgetOpen;
},
});
registry.category("main_components").add("DebugWidget", {
@@ -0,0 +1,21 @@
/** @odoo-module */
import { Component, useEffect } from "@odoo/owl";
export class Loader extends Component {
static template = "point_of_sale.Loader";
static props = { loader: { type: Object, shape: { isShown: Boolean } } };
setup() {
useEffect(
(isShown) => {
if (!isShown) {
// Destroy the loader app after it has faded out
setTimeout(() => {
this.__owl__.app.destroy();
}, 1000);
}
},
() => [this.props.loader.isShown]
);
}
}
@@ -0,0 +1,101 @@
.pos-loader {
background-color: #222;
position: absolute;
left: 0px;
top: 0px;
width: 100%;
height: 100%;
z-index: 100001; // 1 more than the debug widget...
text-align: center;
font-family: Lato;
color: $gray-700;
transition: opacity 0.8s;
.loader-feedback {
width: 400px;
height: 160px;
margin: -60px -200px;
position: absolute;
left: 50%;
top: 50%;
text-align: center;
}
h1 {
font-weight: 300;
}
.button {
display: inline-block;
margin: 25px auto;
line-height: 42px;
padding: 0px 16px;
font-size: 20px;
font-weight: 300;
border: solid 1px;
border-radius: 5px;
cursor: pointer;
:active {
color: #222;
background: $gray-700;
}
}
}
// loader animation
.lds-ellipsis {
display: inline-block;
position: relative;
width: 80px;
height: 60px;
}
.lds-ellipsis div {
position: absolute;
top: 33px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #fff;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 32px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 56px;
animation: lds-ellipsis3 0.6s infinite;
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
@@ -0,0 +1,14 @@
<templates>
<t t-name="point_of_sale.Loader" owl="1">
<div class="pos-loader" t-att-style="props.loader.isShown ? '' : 'opacity: 0'">
<div class="loader-feedback">
<!-- spinner from https://loading.io/css/ -->
<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>
<!-- FIXME POSREF restore skip button? -->
<t t-if="false and pos.loadingSkipButtonIsShown">
<h1 class="message">Connecting to devices</h1>
</t>
</div>
</div>
</t>
</templates>
@@ -11,8 +11,6 @@ import { CashOpeningPopup } from "@point_of_sale/js/Popups/CashOpeningPopup";
import { sprintf } from "@web/core/utils/strings";
export class PosStore extends Reactive {
/** @type {'LOADING' | 'READY' | 'CLOSING'} */
uiState = "LOADING";
hasBigScrollBars = false;
loadingSkipButtonIsShown = false;
mainScreen = { name: null, component: null };
@@ -27,10 +25,10 @@ export class PosStore extends Reactive {
];
constructor() {
super();
this.setup(...arguments);
this.ready = this.setup(...arguments).then(() => this);
}
// use setup instead of constructor because setup can be patched.
setup(env, { popup, orm, number_buffer, hardware_proxy, barcode_reader }) {
async setup(env, { popup, orm, number_buffer, hardware_proxy, barcode_reader }) {
this.orm = orm;
this.popup = popup;
this.numberBuffer = number_buffer;
@@ -40,6 +38,13 @@ export class PosStore extends Reactive {
// FIXME POSREF: the hardwareProxy needs the pos and the pos needs the hardwareProxy. Maybe
// the hardware proxy should just be part of the pos service?
this.hardwareProxy.pos = this.globalState;
await this.globalState.load_server_data();
if (this.globalState.config.use_proxy) {
await this.connectToProxy();
}
this.closeOtherTabs();
this.preloadImages();
this.showScreen("ProductScreen");
}
showScreen(name, props) {
@@ -126,9 +131,6 @@ export class PosStore extends Reactive {
body: reason,
});
if (confirmed) {
// FIXME POSREF setting the location prevents the next render, the loading screen never shows
this.globalState.uiState = "CLOSING";
this.globalState.loadingSkipButtonIsShown = false;
window.location = "/web#action=point_of_sale.action_client_pos_menu";
}
}
@@ -209,12 +211,57 @@ export class PosStore extends Reactive {
this.globalState.pos_session.state == "opening_control"
);
}
preloadImages() {
for (const product of this.globalState.db.get_product_by_category(0)) {
const image = new Image();
image.src = `/web/image?model=product.product&field=image_128&id=${product.id}&unique=${product.write_date}`;
}
for (const category of Object.values(this.globalState.db.category_by_id)) {
if (category.id == 0) {
continue;
}
const image = new Image();
image.src = `/web/image?model=pos.category&field=image_128&id=${category.id}&unique=${category.write_date}`;
}
const image = new Image();
image.src = "/point_of_sale/static/src/img/backspace.png";
}
/**
* Close other tabs that contain the same pos session.
*/
closeOtherTabs() {
// FIXME POSREF use the bus?
localStorage["message"] = "";
localStorage["message"] = JSON.stringify({
message: "close_tabs",
session: this.globalState.pos_session.id,
});
window.addEventListener(
"storage",
(event) => {
if (event.key === "message" && event.newValue) {
const msg = JSON.parse(event.newValue);
if (
msg.message === "close_tabs" &&
msg.session == this.globalState.pos_session.id
) {
console.info("POS / Session opened in another window. EXITING POS");
this.closePos();
}
}
},
false
);
}
}
export const posService = {
dependencies: PosStore.serviceDependencies,
start(env, deps) {
return new PosStore(env, deps);
async start(env, deps) {
return new PosStore(env, deps).ready;
},
};
+12 -5
View File
@@ -2,9 +2,16 @@
import { startWebClient } from "@web/start";
import { Chrome } from "@point_of_sale/js/Chrome";
import { Loader } from "@point_of_sale/app/loader/loader";
import { templates } from "@web/core/assets";
import { mount, reactive, whenReady } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
// FIXME POSREF no we shouldn't but if we did nothing should react, if it does it shouldn't be loaded
// For consistency's sake, we should trigger"WEB_CLIENT_READY" on the bus when PosApp is mounted
// But we can't since mail and some other poll react on that cue, and we don't want those services started
// FIXME POSREF stop using startWebclient: this is not a web client.
startWebClient(Chrome);
const loader = reactive({ isShown: true });
whenReady(() => {
// Show loader as soon as the page is ready, do not wait for services to be started
// as some services load data over RPC and this is why we want to show a loader.
mount(Loader, document.body, { templates, translateFn: _t, props: { loader } });
});
// FIXME POSREF stop using startWebclient?: this is not a web client.
startWebClient(Chrome, { disableLoader: () => (loader.isShown = false) });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 B

+10 -79
View File
@@ -8,7 +8,14 @@ import { MainComponentsContainer } from "@web/core/main_components_container";
import { ErrorHandler } from "@web/core/utils/components";
import { Navbar } from "@point_of_sale/app/navbar/navbar";
import { usePos } from "@point_of_sale/app/pos_hook";
import { useExternalListener, useSubEnv, reactive, onWillUnmount, Component } from "@odoo/owl";
import {
useExternalListener,
useSubEnv,
reactive,
onWillUnmount,
Component,
onMounted,
} from "@odoo/owl";
/**
* Chrome is the root component of the PoS App.
@@ -16,6 +23,7 @@ import { useExternalListener, useSubEnv, reactive, onWillUnmount, Component } fr
export class Chrome extends Component {
static template = "Chrome"; // FIXME POSREF namespace templates
static components = { Transition, MainComponentsContainer, ErrorHandler, Navbar };
static props = { disableLoader: Function };
setup() {
this.pos = usePos();
this.popup = useService("popup");
@@ -51,88 +59,11 @@ export class Chrome extends Component {
}
});
// This is not done in onWillStart because we want to show the loader immediately
this.start();
}
async start() {
await this.pos.globalState.load_server_data();
if (this.pos.globalState.config.use_proxy) {
await this.pos.connectToProxy();
}
this._closeOtherTabs();
this.pos.uiState = "READY";
const { name, props } = this.startScreen;
this.pos.showScreen(name, props);
this.runBackgroundTasks();
onMounted(this.props.disableLoader);
}
// GETTERS //
/**
* Startup screen can be based on pos config so the startup screen
* is only determined after pos data is completely loaded.
*/
get startScreen() {
return { name: "ProductScreen" };
}
// CONTROL METHODS //
runBackgroundTasks() {
// push order in the background, no need to await
this.pos.globalState.push_orders();
// Allow using the app even if not all the images are loaded.
// Basically, preload the images in the background.
this._preloadImages();
}
// MISC METHODS //
_preloadImages() {
for (const product of this.pos.globalState.db.get_product_by_category(0)) {
const image = new Image();
image.src = `/web/image?model=product.product&field=image_128&id=${product.id}&unique=${product.write_date}`;
}
for (const category of Object.values(this.pos.globalState.db.category_by_id)) {
if (category.id == 0) {
continue;
}
const image = new Image();
image.src = `/web/image?model=pos.category&field=image_128&id=${category.id}&unique=${category.write_date}`;
}
const staticImages = ["backspace.png", "bc-arrow-big.png"];
for (const imageName of staticImages) {
const image = new Image();
image.src = `/point_of_sale/static/src/img/${imageName}`;
}
}
/**
* Close other tabs that contain the same pos session.
*/
_closeOtherTabs() {
// FIXME POSREF use the bus?
localStorage["message"] = "";
localStorage["message"] = JSON.stringify({
message: "close_tabs",
session: this.pos.globalState.pos_session.id,
});
window.addEventListener(
"storage",
(event) => {
if (event.key === "message" && event.newValue) {
const msg = JSON.parse(event.newValue);
if (
msg.message === "close_tabs" &&
msg.session == this.pos.globalState.pos_session.id
) {
console.info("POS / Session opened in another window. EXITING POS");
this.pos.closePos();
}
}
},
false
);
}
get showCashMoveButton() {
return Boolean(this.pos.globalState?.config?.cash_control);
}
@@ -231,6 +231,8 @@ export class PosGlobalState extends PosModel {
const { start_category, iface_start_categ_id } = this.config;
this.selectedCategoryId = (start_category && iface_start_categ_id?.[0]) || 0;
this.hasBigScrollBars = this.config.iface_big_scrollbars;
// Push orders in background, do not await
this.push_orders();
this.markReady();
}
@@ -145,57 +145,6 @@ td {
overflow: hidden;
}
/* ********* The black loading screen ********* */
.pos .loader{
background-color: #222;
position:absolute;
left:0px;
top:0px;
width:100%;
height:100%;
z-index: 999;
text-align: center;
font-family: Lato;
color: $gray-700;
}
.pos .loader-feedback{
width: 400px;
height: 160px;
margin: -60px -200px;
position: absolute;
left: 50%; top: 50%;
text-align: center;
}
.pos .loader-feedback h1{
font-weight: 300;
}
.pos .loader-feedback .progressbar{
background: rgb(73,73,73);
height: 1px;
}
.pos .loader-feedback .progressbar > .progress{
height: 100%;
background: white;
width: 0%;
box-shadow: 0px 0px 5px rgba(255,255,255,0.35);
}
.pos .loader-feedback .button{
display: inline-block;
margin: 25px auto;
line-height: 42px;
padding: 0px 16px;
font-size: 20px;
font-weight: 300;
border: solid 1px;
border-radius: 5px;
cursor: pointer;
}
.pos .loader-feedback .button:active{
color: #222;
background: $gray-700;
}
/* ********* Generic Layout Constructs ********* */
.pos .window{
@@ -2921,14 +2870,6 @@ td {
opacity: 0;
}
.o-swing {
transition: opacity 0.8s;
}
.o-swing-enter, .o-swing-leave {
opacity: 0;
}
/*
We block the top-header when a temp screen is displayed.
Similar to blocking the whole ui when a popup is displayed.
@@ -3736,64 +3677,6 @@ td {
}
}
/* For LoadingScreen */
.lds-ellipsis {
display: inline-block;
position: relative;
width: 80px;
height: 60px;
}
.lds-ellipsis div {
position: absolute;
top: 33px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #fff;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 32px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 56px;
animation: lds-ellipsis3 0.6s infinite;
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
.menu {
color: white;
z-index: 1000;
+14 -29
View File
@@ -4,39 +4,24 @@
<t t-name="Chrome" owl="1">
<div class="pos" t-att-class="{ 'big-scrollbars': pos.hasBigScrollBars }">
<div class="pos-receipt-print"></div>
<t t-if="pos.uiState === 'READY'">
<Navbar showCashMoveButton="showCashMoveButton"/>
<div class="pos-content">
<div class="window">
<div class="subwindow">
<div class="subwindow-container">
<div class="subwindow-container-fix screens">
<!-- FIXME POSREF: remove the t-key after owl 2.0.6+ is merged, see https://github.com/odoo/owl/pull/1344 -->
<!-- FIXME POSREF: better error handling in main screens (currently, a crash in owl lifecycle of a main screen blows up the application and the error can't be displayed) -->
<t isShown="!pos.tempScreen" t-component="pos.mainScreen.component"
t-props="pos.mainScreen.props" t-key="pos.mainScreen.component.name"/>
<ErrorHandler onError.bind="onTempScreenError">
<t t-if="pos.tempScreen" t-component="pos.tempScreen.component"
t-props="pos.tempScreen.props" t-key="pos.tempScreen.name" />
</ErrorHandler>
</div>
<Navbar showCashMoveButton="showCashMoveButton"/>
<div class="pos-content">
<div class="window">
<div class="subwindow">
<div class="subwindow-container">
<div class="subwindow-container-fix screens">
<!-- FIXME POSREF: better error handling in main screens (currently, a crash in owl lifecycle of a main screen blows up the application and the error can't be displayed) -->
<t isShown="!pos.tempScreen" t-component="pos.mainScreen.component"
t-props="pos.mainScreen.props"/>
<ErrorHandler onError.bind="onTempScreenError">
<t t-if="pos.tempScreen" t-component="pos.tempScreen.component"
t-props="pos.tempScreen.props" t-key="pos.tempScreen.name" />
</ErrorHandler>
</div>
</div>
</div>
</div>
</t>
<Transition visible="['LOADING', 'CLOSING'].includes(pos.uiState)" name="'o-swing'" leaveDuration="800" t-slot-scope="transition">
<div class="loader" t-att-class="transition.className">
<div class="loader-feedback">
<!-- spinner from https://loading.io/css/ -->
<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>
<t t-if="pos.loadingSkipButtonIsShown">
<h1 class="message">Connecting to devices</h1>
</t>
</div>
</div>
</Transition>
</div>
</div>
<MainComponentsContainer/>
</t>
@@ -1,6 +1,6 @@
/** @odoo-module */
import { Chrome } from "@point_of_sale/js/Chrome";
import { getFixture, mount, nextTick } from "@web/../tests/helpers/utils";
import { getFixture, mount } from "@web/../tests/helpers/utils";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { registry } from "@web/core/registry";
import { posService } from "@point_of_sale/app/pos_store";
@@ -46,11 +46,12 @@ const serverData = {
QUnit.test("mount the Chrome", async (assert) => {
const fixture = getFixture();
const root = await mount(Chrome, fixture, {
assert.verifySteps([]);
await mount(Chrome, fixture, {
env: await makeTestEnv({ serverData }),
test: true,
props: { disableLoader: () => assert.step("disable loader") },
});
await root.env.services.pos.globalState.ready;
await nextTick();
assert.containsOnce(fixture, ".pos");
assert.verifySteps(["disable loader"]);
});
@@ -4,6 +4,12 @@ import { patch } from "@web/core/utils/patch";
import { PosStore } from "@point_of_sale/app/pos_store";
patch(PosStore.prototype, "pos_hr.PosStore", {
async setup() {
await this._super(...arguments);
if (this.globalState.config.module_pos_hr) {
this.showTempScreen("LoginScreen");
}
},
/**
* @override
*/
-6
View File
@@ -4,12 +4,6 @@ import { Chrome } from "@point_of_sale/js/Chrome";
import { patch } from "@web/core/utils/patch";
patch(Chrome.prototype, "pos_hr.Chrome", {
async start() {
await this._super(...arguments);
if (this.env.pos.config.module_pos_hr) {
this.pos.showTempScreen("LoginScreen"); // FIXME POSREF: use startScreen instead.
}
},
get showCashMoveButton() {
return (
this._super(...arguments) &&
@@ -24,13 +24,12 @@ patch(PosStore.prototype, "pos_restaurant.PosStore", {
/**
* @override
*/
setup() {
this._super(...arguments);
this.globalState.ready.then(() => {
if (this.globalState.config.is_table_management) {
this.setActivityListeners();
}
});
async setup() {
await this._super(...arguments);
if (this.globalState.config.is_table_management) {
this.setActivityListeners();
this.showScreen("FloorScreen", { floor: this.globalState.table?.floor || null });
}
},
setActivityListeners() {
IDLE_TIMER_SETTER = this.setIdleTimer.bind(this);
@@ -1,20 +0,0 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { Chrome } from "@point_of_sale/js/Chrome";
patch(Chrome.prototype, "pos_restaurant.Chrome", {
/**
* @override
* FIXME POSREF move to pos store
* `FloorScreen` is the start screen if there are floors.
*/
get startScreen() {
if (this.env.pos.config.is_table_management) {
const table = this.env.pos.table;
return { name: "FloorScreen", props: { floor: table ? table.floor : null } };
} else {
return this._super(...arguments);
}
},
});
@@ -83,11 +83,7 @@ patch(TicketScreen.prototype, "pos_restaurant.TicketScreen", {
if (this.env.pos.config.is_table_management) {
this.env.pos.setOrderToRemove(order);
if (!this.env.pos.table) {
try {
await this.env.pos._removeOrdersFromServer();
} catch (error) {
throw error;
}
await this.env.pos._removeOrdersFromServer();
}
}
await _super(...arguments);
+2 -1
View File
@@ -19,7 +19,7 @@ import { App, whenReady } from "@odoo/owl";
*
* @param {Component} Webclient
*/
export async function startWebClient(Webclient) {
export async function startWebClient(Webclient, props = {}) {
odoo.info = {
db: session.db,
server_version: session.server_version,
@@ -39,6 +39,7 @@ export async function startWebClient(Webclient) {
const app = new App(Webclient, {
name: "Odoo Web Client",
env,
props,
templates,
dev: env.debug,
warnIfNoStaticProps: true,