19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:43 +01:00
parent 4607ccbd2e
commit 825ff6514e
487 changed files with 184979 additions and 195262 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before After
Before After

View file

@ -1,24 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70">
<defs>
<path id="icon-a" d="M4,5.35309892e-14 C36.4160122,9.87060235e-15 58.0836068,-3.97961823e-14 65,5.07020818e-14 C69,6.733808e-14 70,1 70,5 C70,43.0488877 70,62.4235458 70,65 C70,69 69,70 65,70 C61,70 9,70 4,70 C1,70 7.10542736e-15,69 7.10542736e-15,65 C7.25721566e-15,62.4676575 3.83358709e-14,41.8005206 3.60818146e-14,5 C-1.13686838e-13,1 1,5.75716207e-14 4,5.35309892e-14 Z"/>
<linearGradient id="icon-c" x1="100%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#CDC484"/>
<stop offset="100%" stop-color="#B5AA59"/>
</linearGradient>
<path id="icon-d" d="M34.9928486,19.5391573 C35.0500601,19.8604454 36.1442308,25.9991002 36.1442308,28.3438202 C36.1442308,31.9190055 34.1561298,34.4688031 31.216887,35.4941909 L32.1394231,51.7705126 C32.1894832,52.7070335 31.409976,53.5 30.4230769,53.5 L25.8461538,53.5 C24.8664063,53.5 24.0797476,52.7138694 24.1298077,51.7705126 L25.0523437,35.4941909 C22.1059495,34.4688031 20.125,31.9121696 20.125,28.3438202 C20.125,25.9922643 21.2191707,19.8604454 21.2763822,19.5391573 C21.5052284,18.1514658 24.5159856,18.1309581 24.7019231,19.6143524 L24.7019231,29.2666692 C24.7948918,29.4990904 25.7817909,29.4854186 25.8461538,29.2666692 C25.946274,27.5371818 26.4111178,19.7510707 26.4182692,19.5733369 C26.6542668,18.1514658 29.6149639,18.1514658 29.8438101,19.5733369 C29.858113,19.7579067 30.3158053,27.5371818 30.4159255,29.2666692 C30.4802885,29.4854186 31.4743389,29.4990904 31.5601563,29.2666692 L31.5601563,19.6143524 C31.7460938,18.137794 34.7640024,18.1514658 34.9928486,19.5391573 Z M43.5173678,39.0693762 L42.4446514,51.7226612 C42.3588341,52.6796898 43.1526442,53.5 44.1538462,53.5 L48.1586538,53.5 C49.1097957,53.5 49.875,52.7685567 49.875,51.8593796 L49.875,20.1407181 C49.875,19.2383769 49.1097957,18.5000977 48.1586538,18.5000977 C42.2587139,18.5000977 32.3253606,30.7022121 43.5173678,39.0693762 Z"/>
<path id="icon-e" d="M34.9928486,17.5391573 C35.0500601,17.8604454 36.1442308,23.9991002 36.1442308,26.3438202 C36.1442308,29.9190055 34.1561298,32.4688031 31.216887,33.4941909 L32.1394231,49.7705126 C32.1894832,50.7070335 31.409976,51.5 30.4230769,51.5 L25.8461538,51.5 C24.8664063,51.5 24.0797476,50.7138694 24.1298077,49.7705126 L25.0523437,33.4941909 C22.1059495,32.4688031 20.125,29.9121696 20.125,26.3438202 C20.125,23.9922643 21.2191707,17.8604454 21.2763822,17.5391573 C21.5052284,16.1514658 24.5159856,16.1309581 24.7019231,17.6143524 L24.7019231,27.2666692 C24.7948918,27.4990904 25.7817909,27.4854186 25.8461538,27.2666692 C25.946274,25.5371818 26.4111178,17.7510707 26.4182692,17.5733369 C26.6542668,16.1514658 29.6149639,16.1514658 29.8438101,17.5733369 C29.858113,17.7579067 30.3158053,25.5371818 30.4159255,27.2666692 C30.4802885,27.4854186 31.4743389,27.4990904 31.5601563,27.2666692 L31.5601563,17.6143524 C31.7460938,16.137794 34.7640024,16.1514658 34.9928486,17.5391573 Z M43.5173678,37.0693762 L42.4446514,49.7226612 C42.3588341,50.6796898 43.1526442,51.5 44.1538462,51.5 L48.1586538,51.5 C49.1097957,51.5 49.875,50.7685567 49.875,49.8593796 L49.875,18.1407181 C49.875,17.2383769 49.1097957,16.5000977 48.1586538,16.5000977 C42.2587139,16.5000977 32.3253606,28.7022121 43.5173678,37.0693762 Z"/>
</defs>
<g fill="none" fill-rule="evenodd">
<mask id="icon-b" fill="#fff">
<use xlink:href="#icon-a"/>
</mask>
<g mask="url(#icon-b)">
<rect width="70" height="70" fill="url(#icon-c)"/>
<path fill="#FFF" fill-opacity=".383" d="M4,1.8 L65,1.8 C67.6666667,1.8 69.3333333,1.13333333 70,-0.2 C70,2.46666667 70,3.46666667 70,2.8 L1.10547097e-14,2.8 C-1.65952376e-14,3.46666667 -2.9161925e-14,2.46666667 -2.66453526e-14,-0.2 C0.666666667,1.13333333 2,1.8 4,1.8 Z" transform="matrix(1 0 0 -1 0 2.8)"/>
<path fill="#393939" d="M37.1559742,53 L4,52 C2,52 -7.10542736e-15,51.8543417 0,47.9215686 L2.11942169e-16,24.8800004 L21.6437579,0 L21,12.2352941 L26.7780762,0 L29.5940312,3.47275252 L33,0 L35,12.2352941 L41.1357671,3.93586958 L49,0 L49.7896212,33.2154878 L37.1559742,53 Z" opacity=".324" transform="translate(0 17)"/>
<path fill="#000" fill-opacity=".383" d="M4,4 L65,4 C67.6666667,4 69.3333333,3 70,1 C70,3.66666667 70,5 70,5 L1.77635684e-15,5 C1.77635684e-15,5 1.77635684e-15,3.66666667 1.77635684e-15,1 C0.666666667,3 2,4 4,4 Z" transform="translate(0 65)"/>
<use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#icon-d"/>
<use fill="#FFF" fill-rule="nonzero" xlink:href="#icon-e"/>
</g>
</g>
</svg>
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M42 25c0 9.389-7.611 17-17 17S8 34.389 8 25 15.611 8 25 8s17 7.611 17 17Z" fill="#F78613"/><path d="M19.008 40.914C12.576 38.49 8 32.28 8 25c0-9.389 7.611-17 17-17 7.28 0 13.492 4.576 15.914 11.01-2.887 10.638-11.267 19.017-21.906 21.904Z" fill="#FBB945"/><path d="M19.678 15.36c0-1.808-1.52-3.33-3.19-2.639a8.338 8.338 0 0 0-4.513 4.513c-.692 1.67.831 3.19 2.64 3.19h1.063a4 4 0 0 0 4-4v-1.063Z" fill="#fff"/><path d="M8.258 42.696c-.115-.31-.012-.748.232-.976L45.304 7.136c.243-.229.533-.162.649.149.115.311.011.748-.232.976L8.907 42.846c-.244.228-.534.161-.65-.15Z" fill="#1AD3BB"/><path d="M4.036 42.658a.892.892 0 0 1 .332-.961l38.438-25.991c.293-.198.62-.088.729.244a.892.892 0 0 1-.332.961L4.764 42.902c-.293.198-.619.089-.728-.244Z" fill="#03AF89"/></svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 856 B

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View file

@ -1,19 +1,27 @@
/** @odoo-module */
import { rpc } from "@web/core/network/rpc";
import { user } from "@web/core/user";
import { useBus, useService } from "@web/core/utils/hooks";
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
const { Component, useState, onWillStart, markup, xml } = owl;
import { DateTimeInput } from '@web/core/datetime/datetime_input';
import { Component, useState, onWillStart, markup, xml } from "@odoo/owl";
const { DateTime } = luxon;
export class LunchCurrency extends Component {
static template = "lunch.LunchCurrency";
static props = ["currency", "amount"];
get amount() {
return parseFloat(this.props.amount).toFixed(2);
}
}
LunchCurrency.template = 'lunch.LunchCurrency';
LunchCurrency.props = ["currency", "amount"];
export class LunchOrderLine extends Component {
static template = "lunch.LunchOrderLine";
static props = ["line", "currency", "onUpdateQuantity", "openOrderLine", "infos", "isToOrder"];
static components = {
LunchCurrency,
};
setup() {
super.setup();
this.orm = useService('orm');
@ -24,12 +32,19 @@ export class LunchOrderLine extends Component {
return this.props.line;
}
get canAdd(){
let price = this.line.product[3]
this.line.toppings.forEach((line) => price += line[3])
const unpaid = parseFloat(this.props.infos.unpaid_subtotal)
return this.canEdit && (this.props.infos.wallet_with_config - unpaid) >= price;
}
get canEdit() {
return !['sent', 'confirmed'].includes(this.line.raw_state);
}
get badgeClass() {
const mapping = {'new': 'warning', 'confirmed': 'success', 'sent': 'info', 'ordered': 'danger'};
const mapping = {'new': 'secondary', 'confirmed': 'success', 'sent': 'info', 'ordered': 'primary'};
return mapping[this.line.raw_state];
}
@ -46,56 +61,62 @@ export class LunchOrderLine extends Component {
await this.props.onUpdateQuantity();
}
}
LunchOrderLine.template = 'lunch.LunchOrderLine';
LunchOrderLine.props = ["line", "currency", "onUpdateQuantity", "openOrderLine"];
LunchOrderLine.components = {
LunchCurrency,
};
export class LunchAlert extends Component {
static props = ["message"];
static template = xml`<t t-out="message"/>`;
get message() {
return markup(this.props.message);
}
}
LunchAlert.props = ["message"];
LunchAlert.template = xml`<t t-out="message"/>`
export class LunchAlerts extends Component {}
LunchAlerts.components = {
LunchAlert,
export class LunchAlerts extends Component {
static components = {
LunchAlert,
};
static props = ["alerts"];
static template = "lunch.LunchAlerts";
}
LunchAlerts.props = ["alerts"];
LunchAlerts.template = 'lunch.LunchAlerts';
export class LunchUser extends Component {
static components = {
Many2XAutocomplete,
};
static props = ["username", "isManager", "onUpdateUser"];
static template = "lunch.LunchUser";
getDomain() {
return [['share', '=', false]];
}
}
LunchUser.components = {
Many2XAutocomplete,
}
LunchUser.props = ["username", "isManager", "onUpdateUser"];
LunchUser.template = "lunch.LunchUser";
export class LunchLocation extends Component {
static components = {
Many2XAutocomplete,
};
static props = ["location", "onUpdateLunchLocation"];
static template = "lunch.LunchLocation";
getDomain() {
return [];
}
}
LunchLocation.components = {
Many2XAutocomplete,
}
LunchLocation.props = ["location", "onUpdateLunchLocation"];
LunchLocation.template = "lunch.LunchLocation";
export class LunchDashboard extends Component {
static components = {
LunchAlerts,
LunchCurrency,
LunchLocation,
LunchOrderLine,
LunchUser,
Many2XAutocomplete,
DateTimeInput,
};
static props = ["openOrderLine"];
static template = "lunch.LunchDashboard";
setup() {
super.setup();
this.rpc = useService("rpc");
this.user = useService("user");
this.state = useState({
infos: {},
date: DateTime.now(),
});
useBus(this.env.bus, 'lunch_update_dashboard', () => this._fetchLunchInfos());
@ -106,9 +127,9 @@ export class LunchDashboard extends Component {
}
async lunchRpc(route, args = {}) {
return await this.rpc(route, {
return await rpc(route, {
...args,
context: this.user.context,
context: user.context,
user_id: this.env.searchModel.lunchState.userId,
})
}
@ -166,14 +187,9 @@ export class LunchDashboard extends Component {
await this._fetchLunchInfos();
this.env.searchModel.updateLocationId(value[0].id);
}
async onUpdateLunchTime(value) {
this.state.date = value || DateTime.now();
this.env.searchModel.updateDate(this.state.date);
}
}
LunchDashboard.components = {
LunchAlerts,
LunchCurrency,
LunchLocation,
LunchOrderLine,
LunchUser,
Many2XAutocomplete,
};
LunchDashboard.props = ["openOrderLine"];
LunchDashboard.template = 'lunch.LunchDashboard';

View file

@ -1,9 +1,25 @@
.lunch_topping:before {
content: '+ ';
}
.o_lunch_content {
.o-autocomplete--input {
cursor: pointer;
}
}
.o_lunch_banner {
@include media-breakpoint-up(md) {
min-width: 18rem;
}
.o_lunch_order_line_quantity {
input {
max-width: 3rem;
}
.o_lunch_qty_btn.disabled .oi{
opacity: $btn-disabled-opacity;
}
&.o_lunch_order_line_input_disabled {
opacity: $btn-disabled-opacity;
}
}
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="lunch.LunchCurrency" owl="1">
<t t-name="lunch.LunchCurrency">
<span>
<t t-if="props.currency.position == 'before'" t-esc="props.currency.symbol"/>
<t t-esc="amount"/>
@ -8,32 +8,70 @@
</span>
</t>
<t t-name="lunch.LunchOrderLine" owl="1">
<div class="d-flex align-items-center pe-3">
<div class="btn-group">
<button class="btn btn-sm btn-icon btn-link fa fa-minus-circle p-0" t-if="canEdit" t-on-click="() => this.updateQuantity(-1)"/>
<span t-esc="line.quantity" class="px-2 py-1"/>
<button class="btn btn-sm btn-icon btn-link fa fa-plus-circle p-0" t-if="canEdit" t-on-click="() => this.updateQuantity(1)"/>
<t t-name="lunch.LunchOrderLine">
<li
class="d-flex flex-column gap-2 border rounded p-2 w-100 mb-2"
name="o_lunch_order_line"
t-attf-aria-label="{{line.quantity}} {{line.product[1]}} - {{line.state}}"
tabindex="0"
>
<div class="d-flex justify-content-between align-items-start gap-2">
<h6 class="mb-0">
<span t-if="!props.isToOrder"><t t-out="line.quantity"/> x </span>
<t t-out="line.product[1]"/>
<span t-if="!props.isToOrder"> &#8226; <LunchCurrency currency="props.currency" amount="line.product[2]"/></span>
</h6>
<span t-esc="line.state" t-attf-class="badge flex-shrink-0 rounded-pill text-bg-#{badgeClass}"/>
</div>
<span class="flex-grow-1 ps-2 text-700">
<a t-on-click="() => props.openOrderLine(line.product[0], line.id)" t-if="canEdit" role="button" title="Edit order">
<t t-esc="line.product[1]"/>
</a>
<t t-else="" t-esc="line.product[1]"/>
<span t-esc="line.state" t-attf-class="badge ms-2 rounded-pill text-bg-#{badgeClass} border-#{badgeClass} "/>
</span>
<LunchCurrency currency="props.currency" amount="line.product[2]"/>
</div>
<ul t-if="hasToppings" class="list-unstyled ps-4">
<li t-foreach="line.toppings" t-as="topping" t-key="topping" class="d-flex pe-3">
<span class="flex-grow-1 lunch_topping" t-esc="topping[0]"/>
<LunchCurrency currency="props.currency" amount="topping[1]"/>
</li>
</ul>
<div t-if="line.note" t-esc="line.note" class="text-muted ps-4"/>
<div t-if="line.note" class="p-2 rounded bg-100 text-muted">
<i class="fa fa-fw fa-sticky-note pe-2"/>
<t t-out="line.note"/>
</div>
<div class="d-flex justify-content-between">
<span class="text-nowrap" t-esc="line.location"/>
<span class="text-nowrap" t-esc="line.date"/>
</div>
<ul t-if="hasToppings" class="list-group list-group-flush" t-foreach="line.toppings" t-as="topping" t-key="topping">
<li class="list-group-item d-flex justify-content-between ps-2 pe-0 py-1">
<span>+ <t t-esc="topping[0]"/></span>
<LunchCurrency currency="props.currency" amount="topping[1]"/>
</li>
</ul>
<div t-if="props.isToOrder" class="d-flex justify-content-between">
<div class="o_lunch_order_line_quantity input-group">
<button
role="button"
type="button"
t-attf-class="o_lunch_qty_btn btn btn-sm btn-outline-secondary border-end-0 {{canEdit ? '' : 'opacity-100 disabled'}}"
t-on-click="() => this.updateQuantity(-1)"
aria-label="Decrease quantity"
>
<i class="oi oi-minus" role="img"/>
</button>
<input
type="text"
t-attf-class="form-control border border-start-0 border-end-0 text-center bg-view {{canAdd or canEdit ? '' : 'o_lunch_order_line_input_disabled'}}"
t-att-value="line.quantity"
disabled="true"
/>
<button
role="button"
type="button"
t-attf-class="o_lunch_qty_btn btn btn-sm btn-outline-secondary border-start-0 {{canAdd ? '' : 'opacity-100 disabled'}}"
t-on-click="() => this.updateQuantity(1)"
aria-label="Increase quantity"
>
<i class="oi oi-plus" role="img"/>
</button>
</div>
<span class="align-content-end fs-3">
<LunchCurrency currency="props.currency" amount="line.product[2]"/>
</span>
</div>
</li>
</t>
<t t-name="lunch.LunchAlerts" owl="1">
<t t-name="lunch.LunchAlerts">
<div class="alert alert-warning mb-0" t-if="props.alerts.length !== 0" role="alert">
<t t-foreach="props.alerts" t-as="alert" t-key="alert.id">
<LunchAlert message="alert.message" />
@ -41,30 +79,35 @@
</div>
</t>
<t t-name="lunch.LunchUser" owl="1">
<div class="lunch_user pb-1">
<t t-name="lunch.LunchUser">
<div class="lunch_user flex-grow-1">
<span t-if="!props.isManager" t-esc="props.username"/>
<Many2XAutocomplete
t-else=""
value="props.username"
resModel="'res.users'"
getDomain="getDomain"
activeActions="{}"
update.bind="props.onUpdateUser"
/>
<div t-else="" class="o_field_widget w-100">
<Many2XAutocomplete
value="props.username"
resModel="'res.users'"
getDomain="getDomain"
fieldString="props.username"
activeActions="{}"
update.bind="props.onUpdateUser"
/>
</div>
</div>
</t>
<t t-name="lunch.LunchLocation" owl="1">
<div class="lunch_location pb-1">
<t t-name="lunch.LunchLocation">
<div class="lunch_location">
<t t-if="props.location">
<Many2XAutocomplete
value="props.location"
resModel="'lunch.location'"
getDomain="getDomain"
activeActions="{}"
update.bind="props.onUpdateLunchLocation"
/>
<div class="o_field_widget w-100">
<Many2XAutocomplete
value="props.location"
resModel="'lunch.location'"
fieldString="props.location"
getDomain="getDomain"
activeActions="{}"
update.bind="props.onUpdateLunchLocation"
/>
</div>
</t>
<t t-else="">
<p>No lunch location available.</p>
@ -72,71 +115,177 @@
</div>
</t>
<t t-name="lunch.LunchDashboardOrder" owl="1">
<LunchAlerts alerts="state.infos.alerts"/>
<t t-name="lunch.LunchDashboardOrder">
<div class="o_lunch_banner container-fluid p-4 border-bottom bg-view">
<div class="row h-100">
<div class="col-12 col-md-4">
<div class="row h-100 align-content-center">
<div class="col-3">
<img class="o_image_64_cover rounded-circle" t-att-src="state.infos.userimage"/>
</div>
<div class="col-9">
<LunchUser
isManager="state.infos.is_manager"
username="state.infos.username"
onUpdateUser.bind="onUpdateUser"/>
<div class="o_lunch_banner d-flex flex-column border-start h-100 bg-view">
<div class="p-3 overflow-y-auto w-100">
<div class="d-flex flex-column w-100 gap-2">
<LunchAlerts alerts="state.infos.alerts"/>
<div class="d-flex gap-2 align-content-center">
<img class="o_image_24_cover rounded" t-att-src="state.infos.userimage"/>
<LunchUser
isManager="state.infos.is_manager"
username="state.infos.username"
onUpdateUser.bind="onUpdateUser"/>
</div>
<LunchLocation
location="location"
onUpdateLunchLocation.bind="onUpdateLunchLocation"/>
<LunchLocation
location="location"
onUpdateLunchLocation.bind="onUpdateLunchLocation"/>
<div class="d-flex pb-1">
<span class="flex-grow-1">Your Account</span>
<LunchCurrency currency="currency" amount="state.infos.wallet"/>
<div id="lunch_order_date">
<DateTimeInput
type="'date'"
value="this.state.date"
onChange.bind="onUpdateLunchTime"/>
</div>
</div>
<div id="o_lunch_orders" class="o_lunch_widget_line">
<div t-if="hasLines" class="accordion">
<button
t-if="state.infos.lines.some(line => ['sent', 'confirmed'].includes(line.raw_state))"
class="accordion-button collapsed py-2 px-0 bg-view shadow-none"
data-bs-toggle="collapse"
href="#o_lunch_passed_orders"
role="button"
aria-expanded="false"
aria-controls="o_lunch_passed_orders"
>
Passed orders
<span
class="badge rounded-pill ms-2 text-bg-secondary"
t-out="state.infos.lines.filter(line => ['sent', 'confirmed'].includes(line.raw_state)).length"
/>
</button>
<div
id="o_lunch_passed_orders"
class="accordion-collapse collapse"
tabindex="-1"
aria-labelledby="o_lunch_passed_orders"
>
<div class="accordion-body px-0 pt-0">
<ul class="list-unstyled">
<t
t-foreach="state.infos.lines"
t-as="line" t-key="line.id"
t-if="line.raw_state != 'new' &amp;&amp; line.raw_state != 'ordered'"
>
<LunchOrderLine
line="line"
currency="currency"
onUpdateQuantity.bind="onUpdateQuantity"
openOrderLine.bind="props.openOrderLine"
infos="state.infos"
isToOrder="false"
/>
</t>
</ul>
</div>
</div>
</div>
<span
class="d-flex justify-content-between p-2 bg-100 rounded"
t-attf-class="{{hasLines and state.infos.lines.some(line => ['sent', 'confirmed'].includes(line.raw_state)) ? '' : 'mt-2'}}"
name="o_lunch_balance" role="status" tabindex="0" t-attf-aria-label="Available Balance {{state.infos.wallet}} {{currency.symbol}}"
>
<span><i class="fa fa-money me-2"/>Available Balance</span>
<LunchCurrency currency="currency" amount="state.infos.wallet"/>
</span>
<h4 class="mt-3 pt-3 border-top">Your Order</h4>
<p t-if="!(['new', 'ordered'].includes(state.infos.raw_state))" class="text-muted">
Nothing to order, add some meals to begin.
</p>
<t t-if="hasLines">
<ul class="list-unstyled">
<t
t-foreach="state.infos.lines"
t-as="line" t-key="line.id"
t-if="line.raw_state == 'new' || line.raw_state == 'ordered'"
>
<LunchOrderLine
line="line"
currency="currency"
onUpdateQuantity.bind="onUpdateQuantity"
openOrderLine.bind="props.openOrderLine"
infos="state.infos"
isToOrder="true"
/>
</t>
</ul>
</t>
</div>
<div class="col-12 col-md-5" t-if="hasLines">
<h4 class="mb-0">
Your Order
<button t-if="state.infos.raw_state != 'confirmed'" class="btn btn-sm btn-icon btn-link fa fa-trash" t-on-click.prevent="emptyCart"/>
</h4>
<ul class="o_lunch_widget_lines overflow-auto list-unstyled">
<li t-foreach="state.infos.lines" t-as="line" t-key="line.id">
<LunchOrderLine line="line" currency="currency" onUpdateQuantity.bind="onUpdateQuantity" openOrderLine.bind="props.openOrderLine"/>
</li>
</ul>
</div>
<div class="col-12 col-md-3 d-flex flex-column justify-content-between" t-if="hasLines">
<h4 class="d-flex flex-row mt-1">
<span class="flex-grow-1">
Total
</span>
<LunchCurrency currency="currency" amount="state.infos.total"/>
</h4>
<button class="btn btn-primary" t-if="canOrder" t-on-click="orderNow">Order Now</button>
</div>
<div t-if="hasLines" class="mt-auto p-3 border-top">
<span class="d-flex justify-content-between text-muted">
Total
<LunchCurrency currency="currency" amount="state.infos.total"/>
</span>
<span class="d-flex justify-content-between text-muted">
Already Paid
<LunchCurrency currency="currency" amount="state.infos.paid_subtotal"/>
</span>
<h4 class="d-flex justify-content-between">
To Pay
<LunchCurrency currency="currency" amount="state.infos.unpaid_subtotal"/>
</h4>
<div class="d-flex flex-column gap-2" name="o_lunch_order_buttons">
<button
t-if="canOrder"
type="button"
class="btn btn-primary"
t-on-click="orderNow"
t-attf-aria-label="Order Now {{state.infos.unpaid_subtotal}}{{currency.symbol}}"
>
Order Now
</button>
<button
type="button"
t-if="(['new', 'ordered'].includes(state.infos.raw_state))"
class="btn btn-secondary"
t-on-click.prevent="emptyCart">
Clear Order
</button>
</div>
</div>
</div>
</t>
<t t-name="lunch.LunchDashboard" owl="1">
<t t-name="lunch.LunchDashboard">
<t t-set="currency" t-value="state.infos.currency"/>
<t t-if="!env.isSmall">
<t t-call="lunch.LunchDashboardOrder"/>
</t>
<t t-else="">
<details class="fixed-bottom bg-view p-2" t-att-open="state.mobileOpen">
<summary class="btn btn-primary w-100" t-on-click="() => state.mobileOpen = !state.mobileOpen">
<i class="fa fa-fw fa-shopping-cart"/>
<div class="sticky-bottom d-flex gap-2 border-top p-3 pb-4 bg-view">
<button
class="btn btn-primary w-100"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#lunch_order_mobile"
aria-controls="lunch_order_mobile"
>
Your Cart (<LunchCurrency currency="currency" amount="state.infos.total || 0"/>)
</summary>
<t t-call="lunch.LunchDashboardOrder"/>
</details>
</button>
</div>
<div
id="lunch_order_mobile"
class="offcanvas offcanvas-end bg-view"
tabindex="-1"
aria-labelledby="lunch_order_mobile"
>
<div class="offcanvas-header">
<button
type="button"
class="btn btn-secondary oi oi-chevron-left"
data-bs-dismiss="offcanvas"
aria-label="Close"
/>
</div>
<div class="offcanvas-body p-0">
<t t-call="lunch.LunchDashboardOrder">
<t t-set="_user_classes" t-value="'rounded p-3 bg-100'"/>
</t>
</div>
</div>
</t>
</t>
</templates>

View file

@ -0,0 +1,14 @@
import { registry } from "@web/core/registry";
import { booleanFavoriteField } from "@web/views/fields/boolean_favorite/boolean_favorite_field";
export const lunchIsFavoriteField = {
...booleanFavoriteField,
extractProps: (fieldsInfo, dynamicInfo) => {
return {
...booleanFavoriteField.extractProps(fieldsInfo, dynamicInfo),
readonly: Boolean(fieldsInfo.attrs.readonly),
};
},
};
registry.category("fields").add("lunch_is_favorite", lunchIsFavoriteField);

View file

@ -1,14 +1,13 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import { useBus, useService } from "@web/core/utils/hooks";
export const LunchRendererMixin = {
export const LunchRendererMixin = (T) => class LunchRendererMixin extends T {
setup() {
this._super(...arguments);
super.setup(...arguments);
this.action = useService("action");
useBus(this.env.bus, 'lunch_open_order', (ev) => this.openOrderLine(ev.detail.productId));
},
}
openOrderLine(productId, orderId) {
let context = {};
@ -16,10 +15,16 @@ export const LunchRendererMixin = {
if (this.env.searchModel.lunchState.userId) {
context['default_user_id'] = this.env.searchModel.lunchState.userId;
}
if (this.env.searchModel.lunchState.date) {
context['default_date'] = this.env.searchModel.lunchState.date;
}
if (this.env.searchModel.lunchState.locationId) {
context['default_lunch_location_id'] = this.env.searchModel.lunchState.locationId;
}
let action = {
res_model: 'lunch.order',
name: this.env._t('Configure Your Order'),
name: _t('Configure Your Order'),
type: 'ir.actions.act_window',
views: [[false, 'form']],
target: 'new',
@ -36,5 +41,5 @@ export const LunchRendererMixin = {
this.action.doAction(action, {
onClose: () => this.env.bus.trigger('lunch_update_dashboard')
});
},
}
}
};

View file

@ -4,4 +4,10 @@
min-height: auto; // override min-height: 100%
}
}
.o_lunch_content_container {
@include media-breakpoint-up(md) {
overflow: auto;
}
}
}

View file

@ -1,5 +0,0 @@
.o_lunch_widget_lines{
//calculation to display the number of lines to show before scrolling
$-entry-height: calc(#{$font-size-base * $line-height-base} + (#{map-get($spacers, 1)} * 2));
max-height: calc(#{$-entry-height} * 4.5); // if we want 4 entries with the 5th row visible
}

View file

@ -1,17 +1,15 @@
/** @odoo-module */
import { patch } from '@web/core/utils/patch';
import { registry } from '@web/core/registry';
import { kanbanView } from '@web/views/kanban/kanban_view';
import { KanbanRecord } from '@web/views/kanban/kanban_record';
import { KanbanRenderer } from '@web/views/kanban/kanban_renderer';
import { KanbanController } from '@web/views/kanban/kanban_controller';
import { LunchDashboard } from '../components/lunch_dashboard';
import { LunchRendererMixin } from '../mixins/lunch_renderer_mixin';
import { LunchSearchModel } from './search_model';
import { LunchSearchPanel } from './search_panel';
export class LunchKanbanRecord extends KanbanRecord {
onGlobalClick(ev) {
@ -19,9 +17,16 @@ export class LunchKanbanRecord extends KanbanRecord {
}
}
export class LunchKanbanRenderer extends KanbanRenderer {
export class LunchKanbanRenderer extends LunchRendererMixin(KanbanRenderer) {
static template = "lunch.KanbanRenderer";
static components = {
...LunchKanbanRenderer.components,
LunchDashboard,
KanbanRecord: LunchKanbanRecord,
};
getGroupsOrRecords() {
const {locationId} = this.env.searchModel.lunchState;
const { locationId } = this.env.searchModel.lunchState;
if (!locationId) {
return [];
} else {
@ -30,17 +35,19 @@ export class LunchKanbanRenderer extends KanbanRenderer {
}
}
patch(LunchKanbanRenderer.prototype, 'lunch_kanban_renderer_mixin', LunchRendererMixin);
LunchKanbanRenderer.template = 'lunch.KanbanRenderer';
LunchKanbanRenderer.components = {
...LunchKanbanRenderer.components,
LunchDashboard,
KanbanRecord: LunchKanbanRecord,
class LunchKanbanController extends KanbanController {
get modelOptions() {
return {
...super.modelOptions,
lazy: false,
};
}
}
registry.category('views').add('lunch_kanban', {
...kanbanView,
Controller: LunchKanbanController,
Renderer: LunchKanbanRenderer,
SearchModel: LunchSearchModel,
SearchPanel: LunchSearchPanel,
});

View file

@ -1,12 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="lunch.KanbanRenderer" owl="1">
<div class="o_lunch_content d-flex flex-column h-100">
<LunchDashboard openOrderLine.bind="openOrderLine"/>
<div class="overflow-auto flex-grow-1">
<t t-name="lunch.KanbanRenderer">
<div class="o_lunch_content d-flex flex-column flex-md-row h-100">
<div class="o_lunch_content_container flex-grow-1">
<t t-call="lunch.WebKanbanRenderer"/>
</div>
<LunchDashboard openOrderLine.bind="openOrderLine"/>
</div>
</t>

View file

@ -1,21 +1,26 @@
/** @odoo-module */
import { patch } from '@web/core/utils/patch';
import { registry } from '@web/core/registry';
import { listView } from '@web/views/list/list_view';
import { ListRenderer } from '@web/views/list/list_renderer';
import { ListController } from '@web/views/list/list_controller';
import { LunchDashboard } from '../components/lunch_dashboard';
import { LunchRendererMixin } from '../mixins/lunch_renderer_mixin';
import { LunchSearchModel } from './search_model';
import { LunchSearchPanel } from './search_panel';
export class LunchListRenderer extends ListRenderer {
export class LunchListRenderer extends LunchRendererMixin(ListRenderer) {
static template = "lunch.ListRenderer";
static components = {
...LunchListRenderer.components,
LunchDashboard,
};
setup() {
super.setup();
const {locationId} = this.env.searchModel.lunchState;
const { locationId } = this.env.searchModel.lunchState;
if (!locationId) {
this.props.list.records = [];
}
@ -25,16 +30,20 @@ export class LunchListRenderer extends ListRenderer {
this.openOrderLine(record.resId);
}
}
patch(LunchListRenderer.prototype, 'lunch_list_renderer_mixin', LunchRendererMixin);
LunchListRenderer.template = 'lunch.ListRenderer';
LunchListRenderer.components = {
...LunchListRenderer.components,
LunchDashboard,
class LunchListController extends ListController {
get modelOptions() {
return {
...super.modelOptions,
lazy: false,
};
}
}
registry.category('views').add('lunch_list', {
...listView,
Controller: LunchListController,
Renderer: LunchListRenderer,
SearchModel: LunchSearchModel,
SearchPanel: LunchSearchPanel,
});

View file

@ -1,18 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="lunch.ListRenderer" owl="1">
<div class="o_lunch_content d-flex flex-column h-100">
<LunchDashboard openOrderLine.bind="openOrderLine"/>
<div class="overflow-auto flex-grow-1">
<t t-call="lunch.WebListRenderer"/>
</div>
<t t-name="lunch.ListRenderer">
<div class="o_lunch_content d-flex flex-column flex-md-row h-100 overflow-auto">
<t t-call="lunch.WebListRenderer"/>
</div>
<LunchDashboard openOrderLine.bind="openOrderLine"/>
</t>
<t t-name="lunch.WebListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary" owl="1">
<t t-call="web.ActionHelper" position="after">
<ActionHelper position="after">
<t t-call="lunch.NoContentHelper"/>
</t>
</ActionHelper>
</t>
</templates>

View file

@ -1,25 +1,21 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
import { Domain } from '@web/core/domain';
import { rpc } from "@web/core/network/rpc";
import { SearchModel } from '@web/search/search_model';
const { useState, onWillStart } = owl;
import { useState, onWillStart } from "@odoo/owl";
const { DateTime } = luxon;
export class LunchSearchModel extends SearchModel {
setup() {
super.setup(...arguments);
this.rpc = useService('rpc');
this.lunchState = useState({
locationId: false,
userId: false,
date: DateTime.now(),
});
onWillStart(async () => {
const locationId = await this.rpc('/lunch/user_location_get', {});
const locationId = await rpc('/lunch/user_location_get', {});
this.updateLocationId(locationId);
});
}
@ -52,6 +48,17 @@ export class LunchSearchModel extends SearchModel {
this._notify();
}
updateDate(date) {
this.lunchState.date = date;
const weekday = this.lunchState.date.toJSDate().getDay();
const domain_key = ['available_on_sun', 'available_on_mon', 'available_on_tue', 'available_on_wed',
'available_on_thu', 'available_on_fri', 'available_on_sat'][weekday];
const filter = Object.values(this.searchItems).find(o => o['name'] === domain_key);
this.deactivateGroup(filter.groupId)
this.toggleSearchItem(filter.id);
this._notify();
}
_getDomain(params = {}) {
const domain = super._getDomain(params);

View file

@ -0,0 +1,11 @@
import { SearchPanel } from "@web/search/search_panel/search_panel";
import { useService } from "@web/core/utils/hooks"
import { SIZES } from "@web/core/ui/ui_service";
export class LunchSearchPanel extends SearchPanel {
setup() {
super.setup();
this.ui = useService('ui');
this.state.sidebarExpanded = this.ui.size <= SIZES.LG ? false : true;
}
}

View file

@ -0,0 +1,77 @@
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
import { expect, test } from "@odoo/hoot";
import { click } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { defineModels, fields, models, mountView, onRpc } from "@web/../tests/web_test_helpers";
class LunchProduct extends models.Model {
_name = "lunch.product";
name = fields.Char();
is_favorite = fields.Boolean();
_records = [
{
id: 1,
name: "Product A",
},
];
_views = {
kanban: `
<kanban class="o_kanban_test" edit="0">
<template>
<t t-name="card">
<field name="is_favorite" widget="lunch_is_favorite" nolabel="1"/>
<field name="name"/>
</t>
</template>
</kanban>
`,
};
}
defineMailModels();
defineModels([LunchProduct]);
test("Check is_favorite field is still editable even if the record/view is in readonly.", async () => {
onRpc("lunch.product", "web_save", ({ args }) => {
const [ids, vals] = args;
expect(ids).toEqual([1]);
expect(vals).toEqual({ is_favorite: true });
expect.step("web_save");
});
await mountView({
resModel: "lunch.product",
type: "kanban",
});
expect("div[name=is_favorite] .o_favorite").toHaveCount(1);
expect.verifySteps([]);
await click("div[name=is_favorite] .o_favorite");
await animationFrame();
expect.verifySteps(["web_save"]);
});
test("Check is_favorite field is readonly if the field is readonly", async () => {
onRpc("lunch.product", "web_save", () => {
expect.step("web_save");
});
LunchProduct._views["kanban"] = LunchProduct._views["kanban"].replace(
'widget="lunch_is_favorite"',
'widget="lunch_is_favorite" readonly="1"'
);
await mountView({
resModel: "lunch.product",
type: "kanban",
});
expect("div[name=is_favorite] .o_favorite").toHaveCount(1);
expect.verifySteps([]);
await click("div[name=is_favorite] .o_favorite");
await animationFrame();
expect.verifySteps([]);
});

View file

@ -0,0 +1,368 @@
import { LunchKanbanRenderer } from "@lunch/views/kanban";
import { defineMailModels, mailModels } from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import {
contains,
defineModels,
fields,
models,
mountView,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
const lunchInfos = {
username: "Johnny Hache",
wallet: 12.05,
wallet_with_config: 12.05,
is_manager: false,
currency: {
symbol: "€",
position: "after",
},
user_location: [1, "Old Office"],
alerts: [],
lines: [],
};
async function mountLunchView() {
return mountView({
type: "kanban",
resModel: "lunch.product",
arch: `
<kanban js_class="lunch_kanban">
<templates>
<t t-name="card">
<field name="name"/>
<field name="price"/>
</t>
</templates>
</kanban>`,
});
}
class Product extends models.Model {
_name = "lunch.product";
name = fields.Char();
is_available_at = fields.Integer({ string: "Available" });
price = fields.Float();
_records = [
{ id: 1, name: "Big Plate", is_available_at: 1, price: 4.95 },
{ id: 2, name: "Small Plate", is_available_at: 2, price: 6.99 },
{ id: 3, name: "Just One Plate", is_available_at: 2, price: 5.87 },
];
}
class Location extends models.Model {
_name = "lunch.location";
name = fields.Char();
_records = [
{ id: 1, name: "Old Office" },
{ id: 2, name: "New Office" },
];
}
class Order extends models.Model {
_name = "lunch.order";
product_id = fields.Many2one({
string: "Product",
relation: "lunch.product",
});
_views = {
form: `<form>
<sheet>
<field name="product_id" readonly="1"/>
</sheet>
<footer>
<button name="add_to_cart" type="object" string="Add to cart" />
<button string="Discard" special="cancel"/>
</footer>
</form>`,
};
}
defineMailModels();
defineModels([Product, Location, Order]);
describe.current.tags("desktop");
onRpc("/lunch/user_location_get", function () {
return this.env["lunch.location"][0].id;
});
onRpc("/lunch/infos", () => lunchInfos);
test("Basic rendering", async () => {
await mountLunchView();
expect(".o_lunch_banner").toHaveCount(1);
expect(".o_lunch_content .alert").toHaveCount(0);
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1);
expect(".o_lunch_banner .lunch_user span").toHaveText("Johnny Hache");
});
test("Open product", async () => {
expect.assertions(2);
await mountLunchView();
patchWithCleanup(LunchKanbanRenderer.prototype, {
openOrderLine(productId, orderId) {
expect(productId).toBe(1);
},
});
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1);
await contains(".o_kanban_record:not(.o_kanban_ghost)").click();
});
test("Basic rendering with alerts", async () => {
expect.assertions(2);
const userInfos = {
...lunchInfos,
alerts: [
{
id: 1,
message: "<b>free boudin compote for everyone</b>",
},
],
};
onRpc("/lunch/user_location_get", () => userInfos.user_location[0]);
onRpc("/lunch/infos", () => userInfos);
await mountLunchView();
expect(".o_lunch_content .alert").toHaveCount(1);
expect(".o_lunch_content .alert").toHaveText("free boudin compote for everyone");
});
test("Location change", async () => {
expect.assertions(3);
const userInfos = { ...lunchInfos };
onRpc("/lunch/user_location_get", () => userInfos.user_location[0]);
onRpc("/lunch/user_location_set", async (request) => {
const { params } = await request.json();
expect(params.location_id).toBe(2);
userInfos.user_location = [2, "New Office"];
return true;
});
await mountLunchView();
await contains(".lunch_location input").click();
expect(".lunch_location .dropdown-item:contains(New Office)").toHaveCount(1);
await contains(
".lunch_location li:not(.o_m2o_dropdown_option) .dropdown-item:not(.ui-state-active)"
).click();
expect("article.o_kanban_record").toHaveCount(2);
});
test("Manager: user change", async () => {
expect.assertions(8);
mailModels.ResUsers._records.push(
{ id: 1, name: "Johnny Hache" },
{ id: 2, name: "David Elora" }
);
let userInfos = { ...lunchInfos, is_manager: true };
let expectedUserId = false; // false as we are requesting for the current user
onRpc("/lunch/user_location_get", () => userInfos.user_location[0]);
onRpc("/lunch/infos", async (request) => {
const { params } = await request.json();
expect(expectedUserId).toBe(params.user_id);
if (expectedUserId === 2) {
userInfos = {
...userInfos,
username: "David Elora",
wallet: -10000,
};
}
return userInfos;
});
onRpc("/lunch/user_location_set", async (request) => {
const { params } = await request.json();
expect(params.location_id).toBe(2);
userInfos.user_location = [2, "New Office"];
return true;
});
await mountLunchView();
expect(".lunch_user input").toHaveCount(1);
await contains(".lunch_user input").click();
expect(".lunch_user .dropdown-item:contains(David Elora)").toHaveCount(1);
expectedUserId = 2;
await contains(
".lunch_user li:not(.o_m2o_dropdown_option) .dropdown-item:contains('David Elora')"
).click();
expect(".o_lunch_banner span[name='o_lunch_balance']").toHaveText("Available Balance\n-10000.00€", {
message: "David Elora is poor",
});
await contains(".lunch_location input").click();
await contains(".lunch_location li:not(.o_m2o_dropdown_option) .dropdown-item:eq(1)").click();
expect(".lunch_user input").toHaveValue("David Elora", {
message: "changing location should not reset user",
});
});
test("Trash existing order", async () => {
expect.assertions(5);
let userInfos = {
...lunchInfos,
lines: [
{
id: 1,
product: [1, "Big Plate", "4.95", 4.95],
toppings: [],
quantity: 1,
price: 4.95,
raw_state: "new",
state: "To Order",
note: false,
},
],
raw_state: "new",
total: "4.95",
paid_subtotal: "0",
unpaid_subtotal: "4.95",
};
onRpc("/lunch/user_location_get", () => userInfos.user_location[0]);
onRpc("/lunch/infos", () => userInfos);
onRpc("/lunch/trash", () => {
userInfos = {
...userInfos,
lines: [],
raw_state: false,
total: 0,
};
return true;
});
await mountLunchView();
expect("div.o_lunch_banner > div > div").toHaveCount(3);
expect("div.o_lunch_banner div[name='o_lunch_order_buttons'] > button:contains(Clear Order)").toHaveCount(1, {
message: "should have clear order button",
});
expect("div.o_lunch_banner li[name='o_lunch_order_line']").toHaveCount(1, {
message: "should have one order line",
});
expect("div.o_lunch_banner div[name='o_lunch_order_buttons'] > button:contains(Order Now)").toHaveCount(
1
);
await contains("div.o_lunch_banner > div button:contains(Clear Order)").click();
expect("div.o_lunch_banner li[name='o_lunch_order_line']").toHaveCount(0);
});
test("Change existing order", async () => {
expect.assertions(1);
let userInfos = {
...lunchInfos,
lines: [
{
id: 1,
product: [1, "Big Plate", "4.95", 4.95],
toppings: [],
quantity: 1,
price: 4.95,
raw_state: "new",
state: "To Order",
note: false,
},
],
raw_state: "new",
total: "4.95",
paid_subtotal: "0",
unpaid_subtotal: "4.95",
};
onRpc("/lunch/user_location_get", () => userInfos.user_location[0]);
onRpc("/lunch/infos", () => userInfos);
onRpc("lunch.order", "update_quantity", ({ args }) => {
expect(args[1]).toBe(1, { message: "should increment order quantity by 1" });
userInfos = {
...userInfos,
lines: [
{
...userInfos.lines[0],
product: [1, "Big Plate", "9.9", 4.95],
quantity: 2,
price: 4.95 * 2,
},
],
total: 4.95 * 2,
unpaid_subtotal: 4.95 * 2,
};
return true;
});
await mountLunchView();
await contains("div.o_lunch_banner li[name='o_lunch_order_line']:contains(Big Plate) i.oi-plus").click();
});
test("Confirm existing order", async () => {
expect.assertions(3);
let userInfos = {
...lunchInfos,
lines: [
{
id: 1,
product: [1, "Big Plate", "4.95", 4.95],
toppings: [],
quantity: 1,
price: 4.95,
raw_state: "new",
state: "To Order",
note: false,
},
],
raw_state: "new",
total: "4.95",
paid_subtotal: "0",
unpaid_subtotal: "4.95",
};
onRpc("/lunch/user_location_get", () => userInfos.user_location[0]);
onRpc("/lunch/infos", () => userInfos);
onRpc("/lunch/pay", async (request) => {
const { params } = await request.json();
expect(params.user_id).toBe(false); // Should confirm order of current user
userInfos = {
...userInfos,
lines: [
{
...userInfos.lines[0],
raw_state: "ordered",
state: "Ordered,",
},
],
raw_state: "ordered",
wallet: userInfos.wallet - 4.95,
};
return true;
});
await mountLunchView();
expect("div.o_lunch_banner span[name='o_lunch_balance'] span:nth-child(2)").toHaveText("12.05€");
await contains("div.o_lunch_banner div[name='o_lunch_order_buttons'] > button:contains(Order Now)").click();
expect("div.o_lunch_banner span[name='o_lunch_balance'] span:nth-child(2)").toHaveText("7.10€", {
message: "Wallet should update",
});
});

View file

@ -1,204 +0,0 @@
odoo.define('lunch.lunchKanbanMobileTests', function (require) {
"use strict";
const LunchKanbanView = require('lunch.LunchKanbanView');
const testUtils = require('web.test_utils');
const {createLunchView, mockLunchRPC} = require('lunch.test_utils');
QUnit.module('Views');
QUnit.module('LunchKanbanView Mobile', {
beforeEach() {
this.data = {
'product': {
fields: {
is_available_at: {string: 'Product Availability', type: 'many2one', relation: 'lunch.location'},
category_id: {string: 'Product Category', type: 'many2one', relation: 'lunch.product.category'},
supplier_id: {string: 'Vendor', type: 'many2one', relation: 'lunch.supplier'},
},
records: [
{id: 1, name: 'Tuna sandwich', is_available_at: 1},
],
},
'lunch.order': {
fields: {},
update_quantity() {
return Promise.resolve();
},
},
'lunch.product.category': {
fields: {},
records: [],
},
'lunch.supplier': {
fields: {},
records: [],
},
'lunch.location': {
fields: {
name: {string: 'Name', type: 'char'},
},
records: [
{id: 1, name: "Office 1"},
{id: 2, name: "Office 2"},
],
},
};
this.regularInfos = {
user_location: [2, "Office 2"],
};
},
}, function () {
QUnit.test('basic rendering', async function (assert) {
assert.expect(7);
const kanban = await createLunchView({
View: LunchKanbanView,
model: 'product',
data: this.data,
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div><field name="name"/></div>
</t>
</templates>
</kanban>
`,
mockRPC: mockLunchRPC({
infos: this.regularInfos,
userLocation: this.data['lunch.location'].records[0].id,
}),
});
assert.containsOnce(kanban, '.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)',
"should have 1 records in the renderer");
// check view layout
assert.containsOnce(kanban, '.o_content > .o_lunch_content',
"should have a 'kanban lunch wrapper' column");
assert.containsOnce(kanban, '.o_lunch_content > .o_legacy_kanban_view',
"should have a 'classical kanban view' column");
assert.hasClass(kanban.$('.o_legacy_kanban_view'), 'o_lunch_kanban_view',
"should have classname 'o_lunch_kanban_view'");
assert.containsOnce($('.o_lunch_content'), '> details',
"should have a 'lunch kanban' details/summary discolure panel");
assert.hasClass($('.o_lunch_content > details'), 'fixed-bottom',
"should have classname 'fixed-bottom'");
assert.isNotVisible($('.o_lunch_content > details .o_lunch_banner'),
"shouldn't have a visible 'lunch kanban' banner");
kanban.destroy();
});
QUnit.module('LunchWidget', function () {
QUnit.test('toggle', async function (assert) {
assert.expect(6);
const kanban = await createLunchView({
View: LunchKanbanView,
model: 'product',
data: this.data,
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div><field name="name"/></div>
</t>
</templates>
</kanban>
`,
mockRPC: mockLunchRPC({
infos: Object.assign({}, this.regularInfos, {
total: "3.00",
}),
userLocation: this.data['lunch.location'].records[0].id,
}),
});
const $details = $('.o_lunch_content > details');
assert.isNotVisible($details.find('.o_lunch_banner'),
"shouldn't have a visible 'lunch kanban' banner");
assert.isVisible($details.find('> summary'),
"should hava a visible cart toggle button");
assert.containsOnce($details, '> summary:contains(Your cart)',
"should have 'Your cart' in the button text");
assert.containsOnce($details, '> summary:contains(3.00)',
"should have '3.00' in the button text");
await testUtils.dom.click($details.find('> summary'));
assert.isVisible($details.find('.o_lunch_banner'),
"should have a visible 'lunch kanban' banner");
await testUtils.dom.click($details.find('> summary'));
assert.isNotVisible($details.find('.o_lunch_banner'),
"shouldn't have a visible 'lunch kanban' banner");
kanban.destroy();
});
QUnit.test('keep open when adding quantities', async function (assert) {
assert.expect(6);
const kanban = await createLunchView({
View: LunchKanbanView,
model: 'product',
data: this.data,
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div><field name="name"/></div>
</t>
</templates>
</kanban>
`,
mockRPC: mockLunchRPC({
infos: Object.assign({}, this.regularInfos, {
lines: [
{
id: 6,
product: [1, "Tuna sandwich", "3.00"],
toppings: [],
quantity: 1.0,
},
],
}),
userLocation: this.data['lunch.location'].records[0].id,
}),
});
const $details = $('.o_lunch_content > details');
assert.isNotVisible($details.find('.o_lunch_banner'),
"shouldn't have a visible 'lunch kanban' banner");
assert.isVisible($details.find('> summary'),
"should hava a visible cart toggle button");
await testUtils.dom.click($details.find('> summary'));
assert.isVisible($details.find('.o_lunch_banner'),
"should have a visible 'lunch kanban' banner");
const $widgetSecondColumn = kanban.$('.o_lunch_widget .o_lunch_widget_info:eq(1)');
assert.containsOnce($widgetSecondColumn, '.o_lunch_widget_lines > li',
"should have 1 order line");
let $firstLine = $widgetSecondColumn.find('.o_lunch_widget_lines > li:first');
await testUtils.dom.click($firstLine.find('button.o_add_product'));
assert.isVisible($('.o_lunch_content > details .o_lunch_banner'),
"add quantity should keep 'lunch kanban' banner open");
$firstLine = kanban.$('.o_lunch_widget .o_lunch_widget_info:eq(1) .o_lunch_widget_lines > li:first');
await testUtils.dom.click($firstLine.find('button.o_remove_product'));
assert.isVisible($('.o_lunch_content > details .o_lunch_banner'),
"remove quantity should keep 'lunch kanban' banner open");
kanban.destroy();
});
});
});
});

View file

@ -1,415 +0,0 @@
/** @odoo-module */
import { click, getFixture, nextTick, patchWithCleanup } from '@web/../tests/helpers/utils';
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { LunchKanbanRenderer } from '@lunch/views/kanban';
let target;
let serverData;
let lunchInfos;
async function makeLunchView(extraArgs = {}) {
return await makeView(
Object.assign({
serverData,
type: "kanban",
resModel: "lunch.product",
arch: `
<kanban js_class="lunch_kanban">
<templates>
<t t-name="kanban-box">
<div>
<field name="name"/>
<field name="price"/>
</div>
</t>
</templates>
</kanban>`,
mockRPC: (route, args) => {
if (route == '/lunch/user_location_get') {
return Promise.resolve(serverData.models['lunch.location'].records[0].id);
} else if (route == '/lunch/infos') {
return Promise.resolve(lunchInfos);
}
}
}, extraArgs
));
}
QUnit.module('Lunch', {}, function() {
QUnit.module('LunchKanban', (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
'lunch.product': {
fields: {
id: { string: "ID", type: "integer" },
name: { string: 'Name', type: 'char' },
is_available_at: { string: 'Available', type: 'integer' },
price: { string: 'Price', type: 'float', },
},
records: [
{ id: 1, name: "Big Plate", is_available_at: 1, price: 4.95, },
{ id: 2, name: "Small Plate", is_available_at: 2, price: 6.99, },
{ id: 3, name: "Just One Plate", is_available_at: 2, price: 5.87, },
]
},
'lunch.location': {
fields: {
name: { string: 'Name', type: 'char' },
},
records: [
{ id: 1, name: "Old Office" },
{ id: 2, name: "New Office" },
]
},
'lunch.order': {
fields: {
product_id: { string: 'Product', type: 'many2one', relation: 'lunch.product', },
}
},
'res.users': {
fields: {
share: { type: 'boolean', },
},
records: [
{ id: 1, name: 'Johnny Hache', share: false, },
{ id: 2, name: 'David Elora', share: false, }
]
}
},
views: {
'lunch.order,false,form': `<form>
<sheet>
<field name="product_id" readonly="1"/>
</sheet>
<footer>
<button name="add_to_cart" type="object" string="Add to cart" />
<button string="Discard" special="cancel"/>
</footer>
</form>`
}
};
lunchInfos = {
username: "Johnny Hache",
wallet: 12.05,
is_manager: false,
currency: {
symbol: "€",
position: "after",
},
user_location: [1, "Old Office"],
alerts: [],
lines: [],
};
setupViewRegistries();
});
QUnit.test("Basic rendering", async function (assert) {
assert.expect(4);
await makeLunchView();
assert.containsOnce(target, '.o_lunch_banner');
assert.containsNone(target, '.o_lunch_content .alert');
assert.containsOnce(target, '.o_kanban_record:not(.o_kanban_ghost)', 1);
const lunchDashboard = target.querySelector('.o_lunch_banner');
const lunchUser = lunchDashboard.querySelector('.lunch_user span');
assert.equal(lunchUser.innerText, 'Johnny Hache');
});
QUnit.test("Open product", async function (assert) {
assert.expect(2);
await makeLunchView();
patchWithCleanup(LunchKanbanRenderer.prototype, {
openOrderLine(productId, orderId) {
assert.equal(productId, 1);
}
});
assert.containsOnce(target, '.o_kanban_record:not(.o_kanban_ghost)');
click(target, '.o_kanban_record:not(.o_kanban_ghost)');
});
QUnit.test("Basic rendering with alerts", async function (assert) {
assert.expect(2);
let userInfos = {
...lunchInfos,
alerts: [
{
id: 1,
message: '<b>free boudin compote for everyone</b>',
}
]
};
await makeLunchView({
mockRPC: (route, args) => {
if (route == '/lunch/user_location_get') {
return Promise.resolve(userInfos.user_location[0]);
} else if (route == '/lunch/infos') {
return Promise.resolve(userInfos);
}
}
});
assert.containsOnce(target, '.o_lunch_content .alert');
assert.equal(target.querySelector('.o_lunch_content .alert').innerText, 'free boudin compote for everyone');
});
QUnit.test("Open product", async function (assert) {
assert.expect(2);
await makeLunchView();
patchWithCleanup(LunchKanbanRenderer.prototype, {
openOrderLine(productId, orderId) {
assert.equal(productId, 1);
}
});
assert.containsOnce(target, '.o_kanban_record:not(.o_kanban_ghost)');
click(target, '.o_kanban_record:not(.o_kanban_ghost)');
});
QUnit.test("Location change", async function (assert) {
assert.expect(3);
let userInfos = { ...lunchInfos };
await makeLunchView({
mockRPC: (route, args) => {
if (route == '/lunch/user_location_get') {
return Promise.resolve(userInfos.user_location[0]);
} else if (route == '/lunch/infos') {
return Promise.resolve(userInfos);
} else if (route == '/lunch/user_location_set') {
assert.equal(args.location_id, 2);
userInfos.user_location = [2, "New Office"];
return Promise.resolve(true);
}
}
});
click(target, '.lunch_location input');
await nextTick();
assert.containsOnce(target, '.lunch_location .dropdown-item:contains(New Office)');
click(target, '.lunch_location .dropdown-item:not(.ui-state-active)');
await nextTick();
assert.containsN(target, 'div[role=article].o_kanban_record', 2);
});
QUnit.test("Manager: user change", async function (assert) {
assert.expect(8);
let userInfos = { ...lunchInfos, is_manager: true };
let expectedUserId = false; // false as we are requesting for the current user
await makeLunchView({
mockRPC: (route, args) => {
if (route == '/lunch/user_location_get') {
return Promise.resolve(userInfos.user_location[0]);
} else if (route == '/lunch/infos') {
assert.equal(expectedUserId, args.user_id);
if (expectedUserId === 2) {
userInfos = {
...userInfos,
username: 'David Elora',
wallet: -10000,
};
}
return Promise.resolve(userInfos);
} else if (route == '/lunch/user_location_set') {
assert.equal(args.location_id, 2);
userInfos.user_location = [2, "New Office"];
return Promise.resolve(true);
}
}
});
assert.containsOnce(target, '.lunch_user input');
click(target, '.lunch_user input');
await nextTick();
assert.containsOnce(target, '.lunch_user .dropdown-item:contains(David Elora)');
expectedUserId = 2;
click(target, '.lunch_user .dropdown-item:not(.ui-state-active)');
await nextTick();
const wallet = target.querySelector('.o_lunch_banner .col-9 > .d-flex > span:nth-child(2)');
assert.equal(wallet.innerText, '-10000.00€', 'David Elora is poor')
click(target, '.lunch_location input');
await nextTick();
click(target, '.lunch_location .dropdown-item:not(.ui-state-active)');
await nextTick();
const user = target.querySelector('.lunch_user input');
assert.equal(user.value, 'David Elora', 'changing location should not reset user');
});
QUnit.test("Trash existing order", async function (assert) {
assert.expect(5);
let userInfos = {
...lunchInfos,
lines: [
{
id: 1,
product: [1, "Big Plate", "4.95"],
toppings: [],
quantity: 1,
price: 4.95,
raw_state: "new",
state: "To Order",
note: false
}
],
raw_state: "new",
total: "4.95",
};
await makeLunchView({
mockRPC: (route, args) => {
if (route == '/lunch/user_location_get') {
return Promise.resolve(userInfos.user_location[0]);
} else if (route == '/lunch/infos') {
return Promise.resolve(userInfos);
} else if (route == '/lunch/trash') {
userInfos = {
...userInfos,
lines: [],
raw_state: false,
total: 0,
};
return Promise.resolve(true);
}
}
});
assert.containsN(target, 'div.o_lunch_banner > .row > div', 3);
assert.containsOnce(target, 'div.o_lunch_banner > .row > div:nth-child(2) button.fa-trash', 'should have trash icon');
assert.containsOnce(target, 'div.o_lunch_banner > .row > div:nth-child(2) ul > li', 'should have one order line');
assert.containsOnce(target, 'div.o_lunch_banner > .row > div:nth-child(3) button:contains(Order Now)');
click(target, 'div.o_lunch_banner > .row > div:nth-child(2) button.fa-trash');
await nextTick();
assert.containsN(target, 'div.o_lunch_banner > .row > div', 1);
});
QUnit.test("Change existing order", async function (assert) {
assert.expect(1);
let userInfos = {
...lunchInfos,
lines: [
{
id: 1,
product: [1, "Big Plate", "4.95"],
toppings: [],
quantity: 1,
price: 4.95,
raw_state: "new",
state: "To Order",
note: false
}
],
raw_state: "new",
total: "4.95",
};
await makeLunchView({
mockRPC: (route, args) => {
if (route == '/lunch/user_location_get') {
return Promise.resolve(userInfos.user_location[0]);
} else if (route == '/lunch/infos') {
return Promise.resolve(userInfos);
} else if (route == '/web/dataset/call_kw/lunch.order/update_quantity') {
assert.equal(args.args[1], 1, 'should increment order quantity by 1');
userInfos = {
...userInfos,
lines: [
{
...userInfos.lines[0],
product: [1, "Big Plate", "9.9"],
quantity: 2,
price: 4.95 * 2,
}
],
total: 4.95 * 2,
};
return Promise.resolve(true);
}
}
});
click(target, 'div.o_lunch_banner > .row > div:nth-child(2) button.fa-plus-circle');
});
QUnit.test("Confirm existing order", async function (assert) {
assert.expect(3);
let userInfos = {
...lunchInfos,
lines: [
{
id: 1,
product: [1, "Big Plate", "4.95"],
toppings: [],
quantity: 1,
price: 4.95,
raw_state: "new",
state: "To Order",
note: false
}
],
raw_state: "new",
total: "4.95",
};
await makeLunchView({
mockRPC: (route, args) => {
if (route == '/lunch/user_location_get') {
return Promise.resolve(userInfos.user_location[0]);
} else if (route == '/lunch/infos') {
return Promise.resolve(userInfos);
} else if (route == '/lunch/pay') {
assert.equal(args.user_id, false); // Should confirm order of current user
userInfos = {
...userInfos,
lines: [
{
...userInfos.lines[0],
raw_state: 'ordered',
state: 'Ordered,'
}
],
raw_state: 'ordered',
wallet: userInfos.wallet - 4.95,
};
return Promise.resolve(true);
}
}
});
const wallet = target.querySelector('.o_lunch_banner .col-9 > .d-flex > span:nth-child(2)');
assert.equal(wallet.innerText, '12.05€');
click(target, 'div.o_lunch_banner > .row > div:nth-child(3) button');
await nextTick();
assert.equal(wallet.innerText, '7.10€', 'Wallet should update');
});
});
});

View file

@ -1,273 +0,0 @@
odoo.define('lunch.lunchListTests', function (require) {
"use strict";
const LunchListView = require('lunch.LunchListView');
const testUtils = require('web.test_utils');
const {createLunchView, mockLunchRPC} = require('lunch.test_utils');
QUnit.module('Views');
QUnit.module('LunchListView', {
beforeEach() {
const PORTAL_GROUP_ID = 1234;
this.data = {
'product': {
fields: {
is_available_at: {string: 'Product Availability', type: 'many2one', relation: 'lunch.location'},
category_id: {string: 'Product Category', type: 'many2one', relation: 'lunch.product.category'},
supplier_id: {string: 'Vendor', type: 'many2one', relation: 'lunch.supplier'},
},
records: [
{id: 1, name: 'Tuna sandwich', is_available_at: 1},
],
},
'lunch.order': {
fields: {},
update_quantity() {
return Promise.resolve();
},
},
'lunch.product.category': {
fields: {},
records: [],
},
'lunch.supplier': {
fields: {},
records: [],
},
'lunch.location': {
fields: {
name: {string: 'Name', type: 'char'},
company_id: {string: 'Company', type: 'many2one', relation: 'res.company'},
},
records: [
{id: 1, name: "Office 1", company_id: false},
{id: 2, name: "Office 2", company_id: false},
],
},
'res.users': {
fields: {
name: {string: 'Name', type: 'char'},
groups_id: {string: 'Groups', type: 'many2many'},
},
records: [
{id: 1, name: "Mitchell Admin", groups_id: []},
{id: 2, name: "Marc Demo", groups_id: []},
{id: 3, name: "Jean-Luc Portal", groups_id: [PORTAL_GROUP_ID]},
],
},
'res.company': {
fields: {
name: {string: 'Name', type: 'char'},
}, records: [
{id: 1, name: "Dunder Trade Company"},
]
}
};
this.regularInfos = {
username: "Marc Demo",
wallet: 36.5,
is_manager: false,
group_portal_id: PORTAL_GROUP_ID,
currency: {
symbol: "\u20ac",
position: "after"
},
user_location: [2, "Office 2"],
alerts: [{id: 42, message: '<b>Warning! Neurotoxin pressure has reached dangerously unlethal levels.</b>'}]
};
},
}, function () {
QUnit.test('basic rendering', async function (assert) {
assert.expect(9);
const list = await createLunchView({
View: LunchListView,
model: 'product',
data: this.data,
arch: `
<tree>
<field name="name"/>
</tree>
`,
mockRPC: mockLunchRPC({
infos: this.regularInfos,
userLocation: this.data['lunch.location'].records[0].id,
}),
});
// check view layout
assert.containsN(list, '.o_content > div', 2,
"should have 2 columns");
assert.containsOnce(list, '.o_content > div.o_search_panel',
"should have a 'lunch filters' column");
assert.containsOnce(list, '.o_content > .o_lunch_content',
"should have a 'lunch wrapper' column");
assert.containsOnce(list, '.o_lunch_content > .o_legacy_list_view',
"should have a 'classical list view' column");
assert.hasClass(list.$('.o_legacy_list_view'), 'o_lunch_list_view',
"should have classname 'o_lunch_list_view'");
assert.containsOnce(list, '.o_lunch_content > .o_lunch_banner',
"should have a 'lunch' banner");
const $alertMessage = list.$('.alert > *');
assert.equal($alertMessage.length, 1);
assert.equal($alertMessage.prop('tagName'), 'B');
assert.equal($alertMessage.text(), "Warning! Neurotoxin pressure has reached dangerously unlethal levels.")
list.destroy();
});
QUnit.module('LunchWidget', function () {
QUnit.test('search panel domain location', async function (assert) {
assert.expect(18);
let expectedLocation = 1;
let locationId = this.data['lunch.location'].records[0].id;
const regularInfos = _.extend({}, this.regularInfos);
const list = await createLunchView({
View: LunchListView,
model: 'product',
data: this.data,
arch: `
<tree>
<field name="name"/>
</tree>
`,
mockRPC: function (route, args) {
assert.step(route);
if (route.startsWith('/lunch')) {
if (route === '/lunch/user_location_set') {
locationId = args.location_id;
return Promise.resolve(true);
}
return mockLunchRPC({
infos: regularInfos,
userLocation: locationId,
}).apply(this, arguments);
}
if (args.method === 'search_panel_select_multi_range') {
assert.deepEqual(args.kwargs.search_domain, [["is_available_at", "in", [expectedLocation]]],
'The initial domain of the search panel must contain the user location');
}
if (route === '/web/dataset/search_read') {
assert.deepEqual(args.domain, [["is_available_at", "in", [expectedLocation]]],
'The domain for fetching actual data should be correct');
}
return this._super.apply(this, arguments);
},
});
expectedLocation = 2;
await testUtils.fields.many2one.clickOpenDropdown('locations');
await testUtils.fields.many2one.clickItem('locations', "Office 2");
assert.verifySteps([
// Initial state
'/lunch/user_location_get',
'/web/dataset/call_kw/product/search_panel_select_multi_range',
'/web/dataset/call_kw/product/search_panel_select_multi_range',
'/web/dataset/search_read',
'/lunch/infos',
// Click m2o
'/web/dataset/call_kw/lunch.location/name_search',
// Click new location
'/lunch/user_location_set',
'/web/dataset/call_kw/product/search_panel_select_multi_range',
'/web/dataset/call_kw/product/search_panel_select_multi_range',
'/web/dataset/search_read',
'/lunch/infos',
]);
list.destroy();
});
QUnit.test('search panel domain location false: fetch products in all locations', async function (assert) {
assert.expect(9);
const regularInfos = _.extend({}, this.regularInfos);
const list = await createLunchView({
View: LunchListView,
model: 'product',
data: this.data,
arch: `
<tree>
<field name="name"/>
</tree>
`,
mockRPC: function (route, args) {
assert.step(route);
if (route.startsWith('/lunch')) {
return mockLunchRPC({
infos: regularInfos,
userLocation: false,
}).apply(this, arguments);
}
if (args.method === 'search_panel_select_multi_range') {
assert.deepEqual(args.kwargs.search_domain, [],
'The domain should not exist since the location is false.');
}
if (route === '/web/dataset/search_read') {
assert.deepEqual(args.domain, [],
'The domain for fetching actual data should be correct');
}
return this._super.apply(this, arguments);
}
});
assert.verifySteps([
'/lunch/user_location_get',
'/web/dataset/call_kw/product/search_panel_select_multi_range',
'/web/dataset/call_kw/product/search_panel_select_multi_range',
'/web/dataset/search_read',
'/lunch/infos',
])
list.destroy();
});
QUnit.test('add a product', async function (assert) {
assert.expect(1);
const list = await createLunchView({
View: LunchListView,
model: 'product',
data: this.data,
arch: `
<tree>
<field name="name"/>
</tree>
`,
mockRPC: mockLunchRPC({
infos: this.regularInfos,
userLocation: this.data['lunch.location'].records[0].id,
}),
intercepts: {
do_action: function (ev) {
assert.deepEqual(ev.data.action, {
name: "Configure Your Order",
res_model: 'lunch.order',
type: 'ir.actions.act_window',
views: [[false, 'form']],
target: 'new',
context: {
default_product_id: 1,
},
},
"should open the wizard");
},
},
});
await testUtils.dom.click(list.$('.o_data_row:first'));
list.destroy();
});
});
});
});

View file

@ -1,53 +1,53 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
import { _t } from 'web.core';
import tour from 'web_tour.tour';
tour.register('order_lunch_tour', {
url: "/web",
test: true,
}, [
tour.stepUtils.showAppsMenuItem(),
registry.category("web_tour.tours").add('order_lunch_tour', {
url: "/odoo",
steps: () => [
stepUtils.showAppsMenuItem(),
{
trigger: '.o_app[data-menu-xmlid="lunch.menu_lunch"]',
content: _t("Start by accessing the lunch app."),
position: 'bottom',
tooltipPosition: 'bottom',
run: "click",
},
{
content:"click on location",
trigger: ".lunch_location .o_input_dropdown input",
run: 'click',
run: 'click'
},
{
content: "Pick 'Farm 1' option",
trigger: '.o_input_dropdown li:contains(Farm 1)',
trigger: '.dropdown-item:contains("Farm 1")',
run: "click",
},
{
trigger: '.lunch_location input:propValueContains(Farm 1)',
run: () => {}, // wait for article to be correctly loaded
trigger: '.lunch_location input:value("Farm 1")',
},
{
trigger: "div[role='article']",
trigger: ".o_kanban_record",
content: _t("Click on a product you want to order and is available."),
run: 'click'
},
{
trigger: 'textarea[id="note"]',
trigger: 'textarea[id="note_0"]',
content: _t("Add additionnal information about your order."),
position: 'bottom',
run: 'text allergy to peanuts',
tooltipPosition: 'bottom',
run: "edit allergy to peanuts",
},
{
trigger: 'button[name="add_to_cart"]',
content: _t("Add your order to the cart."),
position: 'bottom',
}, {
tooltipPosition: 'bottom',
run: "click",
},
{
trigger: 'button:contains("Order Now")',
content: _t("Validate your order"),
position: 'left',
tooltipPosition: 'left',
run: 'click',
}, {
trigger: '.o_lunch_widget_lines .badge:contains("Ordered")',
trigger: ".o_lunch_widget_line li[name='o_lunch_order_line'] .badge:contains('Ordered')",
content: 'Check that order is ordered',
run: () => {}
}]);
}]});