Eliminate Python dependency: embed frontend assets in odoo-go
- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/ directory (2925 files, 43MB) — no more runtime reads from Python Odoo - Replace OdooAddonsPath config with FrontendDir pointing to local frontend/ - Rewire bundle.go, static.go, templates.go, webclient.go to read from frontend/ instead of external Python Odoo addons directory - Auto-detect frontend/ and build/ dirs relative to binary in main.go - Delete obsolete Python helper scripts (tools/*.py) The Go server is now fully self-contained: single binary + frontend/ folder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BIN
frontend/product/static/demo/acoustic_bloc_screen_document.pdf
Normal file
BIN
frontend/product/static/demo/customizable_desk_document.pdf
Normal file
BIN
frontend/product/static/description/icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
1
frontend/product/static/description/icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M28.235 7.242a6.487 6.487 0 0 0-7.242 2.823l-3.884 6.252a2.418 2.418 0 0 0-.198 2.159l10.002 25.523 17.025-6.636a3.228 3.228 0 0 0 1.839-4.185l-8.823-22.515a2.427 2.427 0 0 0-1.613-1.452l-7.106-1.969Zm-1.092 7.865a1.787 1.787 0 0 0 1.017-2.317 1.795 1.795 0 0 0-2.323-1.015 1.787 1.787 0 0 0-1.017 2.317c.36.92 1.4 1.374 2.323 1.015Z" fill="#2EBCFA"/><path fill-rule="evenodd" clip-rule="evenodd" d="M32.423 10.924a6.482 6.482 0 0 0-6.734-3.877l-7.322.88a2.43 2.43 0 0 0-1.814 1.194L4.435 30.055a3.226 3.226 0 0 0 1.185 4.413l15.831 9.115L35.19 19.852c.382-.66.43-1.462.13-2.163l-2.897-6.764Zm-6.84 4.062c.857.493 1.954.2 2.45-.655a1.786 1.786 0 0 0-.657-2.443 1.796 1.796 0 0 0-2.45.655 1.786 1.786 0 0 0 .657 2.443Z" fill="#985184"/><path fill-rule="evenodd" clip-rule="evenodd" d="M27.78 7.134a6.476 6.476 0 0 1 4.643 3.789l2.897 6.764c.3.701.252 1.503-.13 2.163L24.61 38.124l-7.7-19.649a2.418 2.418 0 0 1 .198-2.158l3.885-6.252a6.487 6.487 0 0 1 6.788-2.931Zm-2.027 7.937a1.786 1.786 0 0 1-.827-2.53 1.796 1.796 0 0 1 2.322-.721 1.787 1.787 0 0 1-.105 3.287 1.792 1.792 0 0 1-1.39-.036Z" fill="#144496"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/product/static/description/icon_hi.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/product/static/img/desk_organizer.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/product/static/img/desk_pad.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/product/static/img/dining_table.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
frontend/product/static/img/glass.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
frontend/product/static/img/leather.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
frontend/product/static/img/linen.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/product/static/img/maroon.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/product/static/img/membership_0-image.jpg
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
frontend/product/static/img/membership_1-image.jpg
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/product/static/img/membership_2-image.jpg
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
frontend/product/static/img/metal.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/product/static/img/monitor_stand.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/product/static/img/office_combo.jpg
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
frontend/product/static/img/placeholder.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/product/static/img/placeholder_thumbnail.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
frontend/product/static/img/product_chair.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/product/static/img/product_lamp.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/product/static/img/product_product_10-image.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/product/static/img/product_product_11-image.jpg
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
frontend/product/static/img/product_product_11b-image.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
frontend/product/static/img/product_product_12-image.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/product/static/img/product_product_13-image.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
frontend/product/static/img/product_product_16-image.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/product/static/img/product_product_20-image.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
frontend/product/static/img/product_product_22-image.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/product/static/img/product_product_24-image.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
frontend/product/static/img/product_product_25-image.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
frontend/product/static/img/product_product_25_black-image.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/product/static/img/product_product_27-image.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/product/static/img/product_product_3-image.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/product/static/img/product_product_43-image.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/product/static/img/product_product_46-image.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
frontend/product/static/img/product_product_5-image.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/product/static/img/product_product_6-image.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/product/static/img/product_product_7-image.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/product/static/img/product_product_8-image.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
frontend/product/static/img/product_product_8_glass-image.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
frontend/product/static/img/product_product_8_metal-image.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/product/static/img/product_product_9-image.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/product/static/img/product_product_d01-image.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/product/static/img/product_product_d01b-image.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/product/static/img/product_product_d01c-image.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/product/static/img/product_product_d03-image.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/product/static/img/purple.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
frontend/product/static/img/table02.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
frontend/product/static/img/table03.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
frontend/product/static/img/table04.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
frontend/product/static/img/velvet.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/product/static/img/wood.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,281 @@
|
||||
import { Component, markup, onRendered, onWillStart, useState } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { download } from "@web/core/network/download";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useSetupAction } from "@web/search/action_hook";
|
||||
import { Layout } from "@web/search/layout";
|
||||
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
|
||||
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
|
||||
|
||||
function sendCustomNotification(type, message) {
|
||||
return {
|
||||
type: "ir.actions.client",
|
||||
tag: "display_notification",
|
||||
params: {
|
||||
"type": type,
|
||||
"message": message
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export class ProductPricelistReport extends Component {
|
||||
static props = { ...standardActionServiceProps };
|
||||
static components = { Layout };
|
||||
static template = "product.ProductPricelistReport";
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.orm = useService("orm");
|
||||
this.dialog = useService("dialog");
|
||||
|
||||
this.MAX_QTY = 5;
|
||||
const pastState = this.props.state || {};
|
||||
|
||||
const active_model = pastState.activeModel || this.props.action.context.active_model;
|
||||
this.noProducts = active_model === 'product.pricelist';
|
||||
this.activeIds = this.noProducts ? [] : pastState.activeIds || this.props.action.context.active_ids;
|
||||
this.activeModel = this.noProducts ? 'product.template' : active_model;
|
||||
this.defaultPricelistId = this.noProducts ? this.props.action.context.active_id : false;
|
||||
|
||||
this.state = useState({
|
||||
displayPricelistTitle: pastState.displayPricelistTitle || false,
|
||||
html: "",
|
||||
pricelists: [],
|
||||
_quantities: pastState.quantities || [1, 5, 10],
|
||||
selectedPricelist: {},
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
this.state.pricelists = await this.getPricelists();
|
||||
if (this.defaultPricelistId) {
|
||||
this.state.selectedPricelist = this.pricelists.find(p => p.id === this.defaultPricelistId) || this.pricelists[0];
|
||||
} else {
|
||||
this.state.selectedPricelist = pastState.selectedPricelist || this.pricelists[0];
|
||||
}
|
||||
if(this.noProducts){
|
||||
await this.onClickAddProducts();
|
||||
}
|
||||
this.renderHtml();
|
||||
});
|
||||
|
||||
onRendered(() => {
|
||||
this.env.config.setDisplayName(_t("Pricelist Report"));
|
||||
});
|
||||
|
||||
/*
|
||||
When following the link of a product and coming back we need to keep the
|
||||
precedent state:
|
||||
- if the pricelist was being showed
|
||||
- wich pricelist is selected at the moment
|
||||
- which quantities
|
||||
*/
|
||||
useSetupAction({
|
||||
getLocalState: () => {
|
||||
return {
|
||||
displayPricelistTitle: this.displayPricelistTitle,
|
||||
quantities: this.quantities,
|
||||
selectedPricelist: this.selectedPricelist,
|
||||
activeModel: this.activeModel,
|
||||
activeIds: this.activeIds,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// getters and setters
|
||||
|
||||
get displayPricelistTitle() {
|
||||
return this.state.displayPricelistTitle;
|
||||
}
|
||||
|
||||
get html() {
|
||||
return this.state.html;
|
||||
}
|
||||
|
||||
get pricelists() {
|
||||
return this.state.pricelists;
|
||||
}
|
||||
|
||||
get quantities() {
|
||||
return this.state._quantities;
|
||||
}
|
||||
|
||||
set quantities(value) {
|
||||
this.state._quantities = value;
|
||||
}
|
||||
|
||||
get reportParams() {
|
||||
return {
|
||||
active_model: this.activeModel || 'product.template',
|
||||
active_ids: this.activeIds || [],
|
||||
display_pricelist_title: this.displayPricelistTitle || '',
|
||||
pricelist_id: this.selectedPricelist.id || '',
|
||||
quantities: this.quantities || [1],
|
||||
};
|
||||
}
|
||||
|
||||
get selectedPricelist() {
|
||||
return this.state.selectedPricelist;
|
||||
}
|
||||
|
||||
// orm calls
|
||||
|
||||
getPricelists() {
|
||||
return this.orm.searchRead("product.pricelist", [], ["id", "name"]);
|
||||
}
|
||||
|
||||
async renderHtml() {
|
||||
if (this.noProducts) {
|
||||
// do not make an rpc to get empty report data
|
||||
this.state.html = "";
|
||||
return
|
||||
}
|
||||
let html = await this.orm.call(
|
||||
"report.product.report_pricelist", "get_html", [], {data: this.reportParams}
|
||||
);
|
||||
this.state.html = markup(html);
|
||||
}
|
||||
|
||||
// events
|
||||
|
||||
async onClickAddQty(ev) {
|
||||
ev.preventDefault(); // avoid automatic reloading of the page
|
||||
|
||||
if (this.quantities.length >= this.MAX_QTY) {
|
||||
let message = _t(
|
||||
"At most %s quantities can be displayed simultaneously. Remove a selected quantity to add others.",
|
||||
this.MAX_QTY
|
||||
);
|
||||
await this.action.doAction(sendCustomNotification("warning", message));
|
||||
return;
|
||||
}
|
||||
|
||||
const qty = parseInt(ev.target.previousSibling.value);
|
||||
if (qty > 0) {
|
||||
// Check qty already exist.
|
||||
if (this.quantities.indexOf(qty) === -1) {
|
||||
this.quantities.push(qty);
|
||||
this.quantities = this.quantities.sort((a, b) => a - b);
|
||||
this.renderHtml();
|
||||
} else {
|
||||
let message = _t("Quantity already present (%s).", qty);
|
||||
await this.action.doAction(sendCustomNotification("info", message));
|
||||
}
|
||||
} else {
|
||||
await this.action.doAction(
|
||||
sendCustomNotification("info", _t("Please enter a positive whole number."))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onClickLink(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
const parent = ev.target.parentElement;
|
||||
|
||||
let classes = parent.getAttribute("class", "");
|
||||
let resModel = parent.getAttribute("data-model", "");
|
||||
let resId = parent.getAttribute("data-res-id", "");
|
||||
|
||||
if (classes && classes.includes("o_action") && resModel && resId) {
|
||||
this.action.doAction({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: resModel,
|
||||
res_id: parseInt(resId),
|
||||
views: [[false, 'form']],
|
||||
target: 'self',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onClickPrint() {
|
||||
if (this.noProducts) {
|
||||
this.action.doAction(
|
||||
sendCustomNotification("warning", _t("Please select some products first."))
|
||||
);
|
||||
return;
|
||||
}
|
||||
const selectedFormat = document.getElementById('formats').value;
|
||||
if (selectedFormat === 'pdf') {
|
||||
this.export_pdf();
|
||||
} else {
|
||||
await this.export_pricelist_csv_xlsx(selectedFormat);
|
||||
}
|
||||
}
|
||||
|
||||
export_pdf() {
|
||||
this.action.doAction({
|
||||
type: 'ir.actions.report',
|
||||
report_type: 'qweb-pdf',
|
||||
report_name: 'product.report_pricelist',
|
||||
report_file: 'product.report_pricelist',
|
||||
data: this.reportParams,
|
||||
});
|
||||
}
|
||||
|
||||
async export_pricelist_csv_xlsx(format) {
|
||||
try {
|
||||
await download({
|
||||
url: `/product/export/pricelist/`,
|
||||
data: {
|
||||
report_data: JSON.stringify(this.reportParams),
|
||||
export_format: format,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error exporting ${format.toUpperCase()} file:`, error);
|
||||
await this.action.doAction(
|
||||
sendCustomNotification(
|
||||
"danger",
|
||||
_t("Error exporting file. Please try again.")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onClickAddProducts() {
|
||||
this.dialog.add(SelectCreateDialog, {
|
||||
resModel: this.activeModel || 'product.template',
|
||||
title: _t("Add Products to pricelist report"),
|
||||
noCreate: true,
|
||||
onSelected: async (resIds) => {
|
||||
resIds.forEach((id) => {
|
||||
if (!this.activeIds.includes(id)) {
|
||||
this.activeIds.push(id);
|
||||
}
|
||||
});
|
||||
this.noProducts = false;
|
||||
await this.renderHtml();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async onClickRemoveQty(ev) {
|
||||
if (this.quantities.length <= 1) {
|
||||
await this.action.doAction(
|
||||
sendCustomNotification("warning", _t("You must leave at least one quantity."))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const qty = parseInt(ev.srcElement.parentElement.childNodes[0].data);
|
||||
this.quantities = this.quantities.filter(q => q !== qty);
|
||||
this.renderHtml();
|
||||
}
|
||||
|
||||
onSelectPricelist(ev) {
|
||||
this.state.selectedPricelist = this.pricelists.filter(pricelist =>
|
||||
pricelist.id === parseInt(ev.target.value)
|
||||
)[0];
|
||||
|
||||
this.renderHtml();
|
||||
}
|
||||
|
||||
onToggleDisplayPricelist() {
|
||||
this.state.displayPricelistTitle = !this.displayPricelistTitle;
|
||||
this.renderHtml();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("generate_pricelist_report", ProductPricelistReport);
|
||||
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates>
|
||||
|
||||
<t t-name="product.ProductPricelistReport">
|
||||
<div class="o_action">
|
||||
<Layout display="{ controlPanel: {} }">
|
||||
<t t-set-slot="control-panel-always-buttons">
|
||||
<button t-on-click="onClickPrint" type="button" class="btn btn-primary" title="Print">Print</button>
|
||||
<select name="formats" id="formats" class="form-select border-1 w-auto">
|
||||
<option value="pdf">pdf</option>
|
||||
<option value="csv">csv</option>
|
||||
<option value="xlsx">xlsx</option>
|
||||
</select>
|
||||
</t>
|
||||
<t t-set-slot="layout-actions">
|
||||
<form class="o_pricelist_report_form d-flex flex-row gap-1">
|
||||
<div class="d-flex align-items-center gap-3 w-100">
|
||||
<label class="visually-hidden" for="pricelists">Username</label>
|
||||
<div class="input-group w-auto">
|
||||
<div class="input-group-text fw-bold">Pricelist</div>
|
||||
<select
|
||||
name="pricelists"
|
||||
id="pricelists"
|
||||
class="o_select form-select border-0"
|
||||
t-on-change="onSelectPricelist"
|
||||
>
|
||||
<option t-out="selectedPricelist.name" t-att-value="selectedPricelist.id"/>
|
||||
<t t-foreach="pricelists" t-as="pricelist" t-key="pricelist.id">
|
||||
<t t-if="pricelist.id != selectedPricelist.id">
|
||||
<option t-out="pricelist.name" t-att-value="pricelist.id"/>
|
||||
</t>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check w-auto">
|
||||
<input class="o_display_pricelist_title form-check-input ms-0 me-2"
|
||||
id="display_pricelist_title"
|
||||
type="checkbox"
|
||||
t-att-checked="displayPricelistTitle"
|
||||
t-on-click="onToggleDisplayPricelist"/>
|
||||
<label for="display_pricelist_title" class="form-check-label">Show Name</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3 w-100">
|
||||
<div class="input-group d-flex flex-nowrap w-50" style="min-width:210px;">
|
||||
<span class="input-group-text fw-bold">
|
||||
Quantities
|
||||
</span>
|
||||
<input type="number"
|
||||
class="form-control add-quantity-input"
|
||||
value="1"
|
||||
min="1"/>
|
||||
<button class="o_add_qty btn btn-primary fa fa-plus"
|
||||
type="submit"
|
||||
t-on-click="onClickAddQty"
|
||||
title="Add a quantity"/>
|
||||
</div>
|
||||
<div class="d-flex align-items-center w-50">
|
||||
<span class="o_badges_list d-flex">
|
||||
<t t-foreach="quantities" t-as="qty" t-key="qty">
|
||||
<span class="text-bg-300 o_remove_qty badge rounded-pill me-2 py-1 border " t-att-value="qty">
|
||||
<t class="me-2" t-esc="qty"/>
|
||||
<i class="oi oi-close ms-1 opacity-50 opacity-100-hover text-900 cursor-pointer"
|
||||
title="Remove quantity"
|
||||
t-on-click="onClickRemoveQty"/>
|
||||
</span>
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div style="margin-left:300px;">
|
||||
<button t-on-click="onClickAddProducts" string="All Products" class="btn btn-link">
|
||||
<i class="fa fa-pencil"/>
|
||||
Add Products
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<div t-on-click="onClickLink">
|
||||
<t t-out="html"/>
|
||||
</div>
|
||||
<t t-if="noProducts">
|
||||
<div class="o_view_nocontent" role="alert">
|
||||
<div class="o_nocontent_help">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No products found in the report
|
||||
</p>
|
||||
<p class="fs-5">
|
||||
Add products using the "Add Products" button at the top right to
|
||||
include them in the report.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</Layout>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { _t } from '@web/core/l10n/translation';
|
||||
import { ConfirmationDialog, deleteConfirmationMessage } from '@web/core/confirmation_dialog/confirmation_dialog';
|
||||
import { ListRenderer } from '@web/views/list/list_renderer';
|
||||
import { registry } from '@web/core/registry';
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
import { X2ManyField, x2ManyField } from '@web/views/fields/x2many/x2many_field';
|
||||
|
||||
|
||||
export class PAVListRenderer extends ListRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dialog = useService("dialog");
|
||||
this.orm = useService("orm");
|
||||
}
|
||||
|
||||
async onDeleteRecord(record) {
|
||||
const message = await this.orm.call(
|
||||
'product.attribute.value',
|
||||
'check_is_used_on_products',
|
||||
[record.resId],
|
||||
)
|
||||
if (message) {
|
||||
return this.dialog.add(ConfirmationDialog, {
|
||||
title: _t("Invalid Operation"),
|
||||
body: message,
|
||||
});
|
||||
}
|
||||
if (record.isNew) {
|
||||
return super.onDeleteRecord(...arguments);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.dialog.add(ConfirmationDialog, {
|
||||
title: _t("Bye-bye, record!"),
|
||||
body: deleteConfirmationMessage,
|
||||
confirmLabel: _t("Delete"),
|
||||
confirm: () => this.onConfirmDelete(record).then(resolve),
|
||||
cancel: resolve,
|
||||
cancelLabel: _t("No, keep it"),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async onConfirmDelete(record) {
|
||||
await this.orm.unlink('product.attribute.value', [record.resId])
|
||||
const res = await super.onDeleteRecord(record);
|
||||
await this.props.list.model.root.save();
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export class PAVOne2ManyField extends X2ManyField {
|
||||
static components = {
|
||||
...X2ManyField.components,
|
||||
ListRenderer: PAVListRenderer,
|
||||
};
|
||||
}
|
||||
|
||||
export const pavOne2ManyField = {
|
||||
...x2ManyField,
|
||||
component: PAVOne2ManyField,
|
||||
}
|
||||
|
||||
registry.category("fields").add("pavs_one2many", pavOne2ManyField);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { UploadButton } from '@product/js/product_document_kanban/upload_button/upload_button';
|
||||
import { KanbanController } from '@web/views/kanban/kanban_controller';
|
||||
|
||||
export class ProductDocumentKanbanController extends KanbanController {
|
||||
static components = { ...KanbanController.components, UploadButton };
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.uploadRoute = '/product/document/upload';
|
||||
this.formData = {
|
||||
'res_model': this.props.context.default_res_model,
|
||||
'res_id': this.props.context.default_res_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t
|
||||
t-name="product.ProductDocumentKanbanView.Buttons"
|
||||
t-inherit="web.KanbanView.Buttons"
|
||||
t-inherit-mode="primary"
|
||||
>
|
||||
<xpath expr="." position="inside">
|
||||
<UploadButton
|
||||
formData="formData"
|
||||
allowedMIMETypes="allowedMIMETypes"
|
||||
load.bind="() => this.model.root.load()"
|
||||
uploadRoute="uploadRoute"
|
||||
/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CANCEL_GLOBAL_CLICK, KanbanRecord } from "@web/views/kanban/kanban_record";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook";
|
||||
|
||||
export class ProductDocumentKanbanRecord extends KanbanRecord {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.store = useService("mail.store");
|
||||
this.fileViewer = useFileViewer();
|
||||
}
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* Override to open the preview upon clicking the image, if compatible.
|
||||
*/
|
||||
onGlobalClick(ev) {
|
||||
if (ev.target.closest(CANCEL_GLOBAL_CLICK)) {
|
||||
return;
|
||||
} else if (ev.target.closest(".o_kanban_previewer")) {
|
||||
const attachment = this.store["ir.attachment"].insert({
|
||||
id: this.props.record.data.ir_attachment_id.id,
|
||||
name: this.props.record.data.name,
|
||||
mimetype: this.props.record.data.mimetype,
|
||||
});
|
||||
this.fileViewer.open(attachment);
|
||||
return;
|
||||
}
|
||||
return super.onGlobalClick(...arguments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
|
||||
import { ProductDocumentKanbanRecord } from "@product/js/product_document_kanban/product_document_kanban_record";
|
||||
import { FileUploadProgressContainer } from "@web/core/file_upload/file_upload_progress_container";
|
||||
import { FileUploadProgressKanbanRecord } from "@web/core/file_upload/file_upload_progress_record";
|
||||
|
||||
export class ProductDocumentKanbanRenderer extends KanbanRenderer {
|
||||
static components = {
|
||||
...KanbanRenderer.components,
|
||||
FileUploadProgressContainer,
|
||||
FileUploadProgressKanbanRecord,
|
||||
KanbanRecord: ProductDocumentKanbanRecord,
|
||||
};
|
||||
static template = "product.ProductDocumentKanbanRenderer";
|
||||
setup() {
|
||||
super.setup();
|
||||
this.fileUploadService = useService("file_upload");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="product.ProductDocumentKanbanRenderer" t-inherit-mode="primary" t-inherit="web.KanbanRenderer">
|
||||
<!-- Before the first t-foreach -->
|
||||
<xpath expr="//t[@t-key='groupOrRecord.key']" position="before">
|
||||
<FileUploadProgressContainer fileUploads="fileUploadService.uploads" Component="constructor.components.FileUploadProgressKanbanRecord"/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||
import { ProductDocumentKanbanController } from "@product/js/product_document_kanban/product_document_kanban_controller";
|
||||
import { ProductDocumentKanbanRenderer } from "@product/js/product_document_kanban/product_document_kanban_renderer";
|
||||
|
||||
export const productDocumentKanbanView = {
|
||||
...kanbanView,
|
||||
Controller: ProductDocumentKanbanController,
|
||||
Renderer: ProductDocumentKanbanRenderer,
|
||||
buttonTemplate: "product.ProductDocumentKanbanView.Buttons",
|
||||
};
|
||||
|
||||
registry.category("views").add("product_documents_kanban", productDocumentKanbanView);
|
||||
@@ -0,0 +1,3 @@
|
||||
.o_kanban_previewer:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Component, useRef } from "@odoo/owl";
|
||||
import { useBus, useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class UploadButton extends Component {
|
||||
static template = "product.UploadButton";
|
||||
static props = {
|
||||
formData: { type: Object, optional: true},
|
||||
// See https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||
allowedMIMETypes: { type: String, optional: true},
|
||||
load: Function,
|
||||
uploadRoute: String,
|
||||
}
|
||||
static defaultProps = {
|
||||
formData: {},
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.uploadFileInputRef = useRef("uploadFileInput");
|
||||
this.fileUploadService = useService("file_upload");
|
||||
this.notification = useService('notification');
|
||||
useBus(
|
||||
this.fileUploadService.bus,
|
||||
"FILE_UPLOAD_LOADED",
|
||||
async () => {
|
||||
await this.props.load();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async onFileInputChange(ev) {
|
||||
const files = [...ev.target.files].filter(file => this.validFileType(file));
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
await this.fileUploadService.upload(
|
||||
this.props.uploadRoute,
|
||||
files,
|
||||
{
|
||||
buildFormData: (formData) => this.buildFormData(formData)
|
||||
},
|
||||
);
|
||||
// Reset the file input's value so that the same file may be uploaded twice.
|
||||
ev.target.value = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The `allowedMIMETypes` prop can restrict the file types users are guided to select. However,
|
||||
* the `accept` attribute doesn't enforce strict validation; it only suggests file types for
|
||||
* browsers.
|
||||
*
|
||||
* @param {File} file
|
||||
* @returns Whether the upload file's type is in the whitelist (`allowedMIMETypes`).
|
||||
*/
|
||||
validFileType(file) {
|
||||
if (this.props.allowedMIMETypes && !this.props.allowedMIMETypes.includes(file.type)) {
|
||||
this.notification.add(
|
||||
_t(`Oops! '%(fileName)s' didn’t upload since its format isn’t allowed.`, {
|
||||
fileName: file.name,
|
||||
}),
|
||||
{
|
||||
type: "danger",
|
||||
}
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
buildFormData(formData) {
|
||||
for (const [key, value] of Object.entries(this.props.formData)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="product.UploadButton">
|
||||
<input
|
||||
type="file"
|
||||
multiple="true"
|
||||
t-ref="uploadFileInput"
|
||||
t-att-accept="props.allowedMIMETypes"
|
||||
class="o_input_file o_hidden"
|
||||
t-on-change.stop="onFileInputChange"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
name="product_upload_document"
|
||||
t-attf-class="btn btn-primary"
|
||||
t-on-click.stop.prevent="() => this.uploadFileInputRef.el.click()"
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { KanbanController } from "@web/views/kanban/kanban_controller";
|
||||
import { onWillStart } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useDebounced } from "@web/core/utils/timing";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class ProductCatalogKanbanController extends KanbanController {
|
||||
static template = "ProductCatalogKanbanController";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.orderId = this.props.context.order_id;
|
||||
this.orderResModel = this.props.context.product_catalog_order_model;
|
||||
this.backToQuotationDebounced = useDebounced(this.backToQuotation, 500)
|
||||
|
||||
onWillStart(() => this.onWillStart());
|
||||
}
|
||||
|
||||
async onWillStart() {
|
||||
await this.setOrderStateInfo();
|
||||
this._defineButtonContent();
|
||||
}
|
||||
|
||||
// Force the slot for the "Back to Quotation" button to always be shown.
|
||||
get canCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get stateFiels() {
|
||||
return ["state"];
|
||||
}
|
||||
|
||||
async setOrderStateInfo() {
|
||||
const orderData = await this.orm.searchRead(
|
||||
this.orderResModel, [["id", "=", this.orderId]], this.stateFiels
|
||||
);
|
||||
this.orderStateInfo = orderData[0] || {};
|
||||
}
|
||||
|
||||
_defineButtonContent() {
|
||||
// Define the button's label depending of the order's state.
|
||||
const orderIsQuotation = ["draft", "sent"].includes(this.orderStateInfo.state);
|
||||
if (orderIsQuotation) {
|
||||
this.buttonString = _t("Back to Quotation");
|
||||
} else {
|
||||
this.buttonString = _t("Back to Order");
|
||||
}
|
||||
}
|
||||
|
||||
async backToQuotation() {
|
||||
// Restore the last form view from the breadcrumbs if breadcrumbs are available.
|
||||
// If, for some weird reason, the user reloads the page then the breadcrumbs are
|
||||
// lost, and we fall back to the form view ourselves.
|
||||
if (this.env.config.breadcrumbs.length > 1) {
|
||||
await this.actionService.restore();
|
||||
} else {
|
||||
await this.actionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: this.orderResModel,
|
||||
views: [[false, "form"]],
|
||||
view_mode: "form",
|
||||
res_id: this.orderId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="ProductCatalogKanbanController" t-inherit="web.KanbanView" t-inherit-mode="primary">
|
||||
<xpath expr="//button[hasclass('o-kanban-button-new')]" position="replace">
|
||||
<button t-out="this.buttonString" type="button" class="btn btn-primary o-kanban-button-back" t-on-click="this.backToQuotationDebounced"/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
81
frontend/product/static/src/product_catalog/kanban_model.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { Record } from "@web/model/relational_model/record";
|
||||
import { RelationalModel } from "@web/model/relational_model/relational_model";
|
||||
|
||||
class ProductCatalogRecord extends Record {
|
||||
setup(config, data, options = {}) {
|
||||
this.productCatalogData = data.productCatalogData;
|
||||
data = { ...data };
|
||||
delete data.productCatalogData;
|
||||
super.setup(config, data, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class ProductCatalogKanbanModel extends RelationalModel {
|
||||
static Record = ProductCatalogRecord;
|
||||
static withCache = false;
|
||||
|
||||
async _loadData(params) {
|
||||
// if orm have isSample field and its value set to be true then we have sample data as there is no product found for selected vendor, show sample data
|
||||
const isSample = this.orm.isSample !== undefined ? this.orm.isSample : false;
|
||||
const result = await super._loadData(...arguments);
|
||||
if (!params.isMonoRecord) {
|
||||
let records;
|
||||
if (params.groupBy?.length) {
|
||||
// web_read_group: find all opened records from (sub)group
|
||||
records = [];
|
||||
const stackGroups = [...result.groups];
|
||||
while (stackGroups.length) {
|
||||
const group = stackGroups.pop();
|
||||
if (group.groups?.length) {
|
||||
stackGroups.push(...group.groups);
|
||||
}
|
||||
if (group.records?.length) {
|
||||
records.push(...group.records);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
records = result.records;
|
||||
}
|
||||
|
||||
let orderLinesInfo;
|
||||
if (!isSample) {
|
||||
orderLinesInfo = await rpc("/product/catalog/order_lines_info", this._getOrderLinesInfoParams(params, records.map((rec) => rec.id)));
|
||||
} else {
|
||||
orderLinesInfo = this._getSampleOrderLineInfo();
|
||||
}
|
||||
for (const record of records) {
|
||||
record.productCatalogData = orderLinesInfo[record.id];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
_getOrderLinesInfoParams(params, productIds) {
|
||||
return {
|
||||
order_id: params.context.order_id,
|
||||
product_ids: productIds,
|
||||
res_model: params.context.product_catalog_order_model,
|
||||
child_field: params.context.child_field,
|
||||
}
|
||||
}
|
||||
|
||||
_getSampleOrderLineInfo() {
|
||||
// this function only returns data for sample view similar to rpc call ("/product/catalog/order_lines_info) made in _loadData
|
||||
const sampleOrderLineInfo = {};
|
||||
const numRecords = 10; // Number of records to generate
|
||||
for (let i = 1; i <= numRecords; i++) {
|
||||
sampleOrderLineInfo[i] = {
|
||||
isSample: true,
|
||||
quantity: Math.floor(Math.random() * 10),
|
||||
min_qty: 0,
|
||||
price: Math.floor(Math.random() * 500) + 100,
|
||||
productType: "consu",
|
||||
readOnly: false,
|
||||
uomDisplayName: _t("Units"),
|
||||
};
|
||||
}
|
||||
return sampleOrderLineInfo;
|
||||
}
|
||||
}
|
||||
135
frontend/product/static/src/product_catalog/kanban_record.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useSubEnv } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useDebounced } from "@web/core/utils/timing";
|
||||
import { KanbanRecord } from "@web/views/kanban/kanban_record";
|
||||
import { ProductCatalogOrderLine } from "./order_line/order_line";
|
||||
|
||||
export class ProductCatalogKanbanRecord extends KanbanRecord {
|
||||
static template = "ProductCatalogKanbanRecord";
|
||||
static components = {
|
||||
...KanbanRecord.components,
|
||||
ProductCatalogOrderLine,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.debouncedUpdateQuantity = useDebounced(this._updateQuantity, 500, {
|
||||
execBeforeUnmount: true,
|
||||
});
|
||||
this._pendingUpdate = Promise.resolve();
|
||||
|
||||
useSubEnv({
|
||||
currencyId: this.props.record.context.product_catalog_currency_id,
|
||||
orderId: this.props.record.context.product_catalog_order_id,
|
||||
orderResModel: this.props.record.context.product_catalog_order_model,
|
||||
digits: this.props.record.context.product_catalog_digits,
|
||||
displayUoM: this.props.record.context.display_uom,
|
||||
precision: this.props.record.context.precision,
|
||||
productId: this.props.record.resId,
|
||||
addProduct: this.addProduct.bind(this),
|
||||
removeProduct: this.removeProduct.bind(this),
|
||||
increaseQuantity: this.increaseQuantity.bind(this),
|
||||
setQuantity: this.setQuantity.bind(this),
|
||||
decreaseQuantity: this.decreaseQuantity.bind(this),
|
||||
childField: this.props.record.context.child_field,
|
||||
});
|
||||
}
|
||||
|
||||
get orderLineComponent() {
|
||||
return ProductCatalogOrderLine;
|
||||
}
|
||||
|
||||
get productCatalogData() {
|
||||
return this.props.record.productCatalogData;
|
||||
}
|
||||
|
||||
onGlobalClick(ev) {
|
||||
// avoid a concurrent update when clicking on the buttons (that are inside the record)
|
||||
if (ev.target.closest(".o_product_catalog_cancel_global_click")) {
|
||||
return;
|
||||
}
|
||||
if (this.productCatalogData.quantity === 0) {
|
||||
this.addProduct();
|
||||
} else {
|
||||
this.increaseQuantity();
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Data Exchanges
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
async _updateQuantity() {
|
||||
const price = await this._updateQuantityAndGetPrice();
|
||||
this.productCatalogData.price = parseFloat(price);
|
||||
}
|
||||
|
||||
_updateQuantityAndGetPrice() {
|
||||
// Chain RPC calls to ensure that each request is completed before starting the next one.
|
||||
// This prevents race conditions and ensures the server processes updates sequentially.
|
||||
this._pendingUpdate = this._pendingUpdate.then(() => rpc(
|
||||
"/product/catalog/update_order_line_info",
|
||||
this._getUpdateQuantityAndGetPriceParams(),
|
||||
));
|
||||
return this._pendingUpdate;
|
||||
}
|
||||
|
||||
_getUpdateQuantityAndGetPriceParams() {
|
||||
return {
|
||||
order_id: this.env.orderId,
|
||||
product_id: this.env.productId,
|
||||
quantity: this.productCatalogData.quantity,
|
||||
res_model: this.env.orderResModel,
|
||||
child_field: this.env.childField,
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
updateQuantity(quantity) {
|
||||
if (this.productCatalogData.readOnly) {
|
||||
return;
|
||||
}
|
||||
this.productCatalogData.quantity = quantity || 0;
|
||||
this.debouncedUpdateQuantity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the product to the order
|
||||
*/
|
||||
addProduct(qty=1) {
|
||||
this.updateQuantity(qty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the product to the order
|
||||
*/
|
||||
removeProduct() {
|
||||
this.updateQuantity(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increase the quantity of the product on the order line.
|
||||
*/
|
||||
increaseQuantity(qty=1) {
|
||||
this.updateQuantity(this.productCatalogData.quantity + qty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the quantity of the product on the order line.
|
||||
*
|
||||
* @param {Event} event
|
||||
*/
|
||||
setQuantity(event) {
|
||||
this.updateQuantity(parseFloat(event.target.value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrease the quantity of the product on the order line.
|
||||
*/
|
||||
decreaseQuantity() {
|
||||
this.updateQuantity(parseFloat(this.productCatalogData.quantity - 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="ProductCatalogKanbanRecord">
|
||||
<article
|
||||
t-att-class="getRecordClasses() + (productCatalogData.quantity ? ' o_product_added' : '')"
|
||||
t-att-data-id="props.record.id"
|
||||
t-att-tabindex="props.record.model.useSampleModel ? -1 : 0"
|
||||
t-on-click="onGlobalClick"
|
||||
t-ref="root">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<t t-call="{{ templates[this.constructor.KANBAN_CARD_ATTRIBUTE] }}"
|
||||
t-call-context="this.renderingContext"/>
|
||||
<t t-component="orderLineComponent" productId="props.record.resId" t-props="productCatalogData"/>
|
||||
</div>
|
||||
<t t-call="{{ this.constructor.menuTemplate }}"/>
|
||||
</article>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { ProductCatalogKanbanRecord } from "./kanban_record";
|
||||
|
||||
export class ProductCatalogKanbanRenderer extends KanbanRenderer {
|
||||
static template = "ProductCatalogKanbanRenderer";
|
||||
static components = {
|
||||
...KanbanRenderer.components,
|
||||
KanbanRecord: ProductCatalogKanbanRecord,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.action = useService("action");
|
||||
}
|
||||
|
||||
get createProductContext() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async createProduct() {
|
||||
await this.action.doAction(
|
||||
{
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "product.product",
|
||||
target: "new",
|
||||
views: [[false, "form"]],
|
||||
view_mode: "form",
|
||||
context: this.createProductContext,
|
||||
},
|
||||
{
|
||||
onClose: () => this.props.list.model.load(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="ProductCatalogKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary">
|
||||
<t t-if="showNoContentHelper" position="replace">
|
||||
<t t-if="showNoContentHelper">
|
||||
<div class="o_view_nocontent" role="alert">
|
||||
<div class="o_nocontent_help">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No products could be found.<br/>
|
||||
<button class="mt-2 btn btn-primary" t-on-click="this.createProduct">Create a product</button>
|
||||
</p>
|
||||
<p>
|
||||
You must define a product for everything you sell or purchase,
|
||||
whether it's a storable product, a consumable or a service.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
16
frontend/product/static/src/product_catalog/kanban_view.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
import { ProductCatalogKanbanController } from "./kanban_controller";
|
||||
import { ProductCatalogKanbanModel } from "./kanban_model";
|
||||
import { ProductCatalogKanbanRenderer } from "./kanban_renderer";
|
||||
|
||||
|
||||
export const productCatalogKanbanView = {
|
||||
...kanbanView,
|
||||
Controller: ProductCatalogKanbanController,
|
||||
Model: ProductCatalogKanbanModel,
|
||||
Renderer: ProductCatalogKanbanRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("product_kanban_catalog", productCatalogKanbanView);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Component } from "@odoo/owl";
|
||||
import { formatFloat, formatMonetary } from "@web/views/fields/formatters";
|
||||
|
||||
export class ProductCatalogOrderLine extends Component {
|
||||
static template = "product.ProductCatalogOrderLine";
|
||||
static props = {
|
||||
isSample: { type: Boolean, optional: true},
|
||||
productId: Number,
|
||||
quantity: Number,
|
||||
price: Number,
|
||||
productType: String,
|
||||
uomDisplayName: String,
|
||||
uomFactor: { type: Number, optional: true },
|
||||
code: { type: String, optional: true},
|
||||
readOnly: { type: Boolean, optional: true },
|
||||
warning: { type: String, optional: true},
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus input text when clicked
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onFocus(ev) {
|
||||
ev.target.select();
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
isInOrder() {
|
||||
return this.props.quantity !== 0;
|
||||
}
|
||||
|
||||
get disableRemove() {
|
||||
return false;
|
||||
}
|
||||
|
||||
get disabledButtonTooltip() {
|
||||
return "";
|
||||
}
|
||||
|
||||
get price() {
|
||||
const { currencyId, digits } = this.env;
|
||||
return formatMonetary(this.props.price, { currencyId, digits });
|
||||
}
|
||||
|
||||
get quantity() {
|
||||
const digits = [false, this.env.precision];
|
||||
const options = { digits, decimalPoint: ".", thousandsSep: "" };
|
||||
return parseFloat(formatFloat(this.props.quantity, options));
|
||||
}
|
||||
|
||||
get showPrice() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
div.o_product_catalog_quantity {
|
||||
input[type="number"] {
|
||||
// Remove arrow buttons input type="number"
|
||||
appearance: textfield;
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_kanban_view .o_kanban_renderer .o_kanban_record.o_product_added {
|
||||
background-color: $o-component-active-bg;
|
||||
border-color: $o-component-active-border;
|
||||
z-index: 3; // Makes sure bottom border in groupby view is above
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="product.ProductCatalogOrderLine">
|
||||
<!-- Replace the element found using the css selector by the content of the portalled
|
||||
template. -->
|
||||
<t t-portal="`#product-${props.productId}-price`">
|
||||
<div class="d-inline-flex align-items-baseline">
|
||||
<span t-if="showPrice" t-out="price" class="o_product_catalog_price fw-bold me-4"/>
|
||||
<span t-if="props.code" t-out="props.code" class="text-muted"/>
|
||||
</div>
|
||||
</t>
|
||||
<div
|
||||
t-if="props.readOnly and props.warning"
|
||||
class="text-danger text-truncate my-2 pt-3 border-top">
|
||||
<i class="fa fa-warning" t-att-title="props.warning"/>
|
||||
<span
|
||||
class="px-1"
|
||||
t-att-title="props.warning"
|
||||
t-out="props.warning"/>
|
||||
</div>
|
||||
<span t-elif="props.readOnly" class="m-2 pt-3 border-top" t-out="props.warning">
|
||||
You can't edit this product in the catalog.
|
||||
</span>
|
||||
<div t-else="" class="d-flex justify-content-end align-items-center m-2">
|
||||
<div t-if="isInOrder()"
|
||||
class="input-group o_product_catalog_quantity o_product_catalog_cancel_global_click w-50">
|
||||
<div class="d-flex">
|
||||
<button class="btn btn-primary border"
|
||||
t-on-click.stop="this.env.decreaseQuantity"
|
||||
t-att-disabled="disableRemove"
|
||||
t-att-data-tooltip="disabledButtonTooltip">
|
||||
<i class="fa fa-minus center"/>
|
||||
</button>
|
||||
<div class="d-flex w-100">
|
||||
<input class="o_input text-center text-bg-light rounded-0 border border-end-0"
|
||||
type="number"
|
||||
t-att-class="this.env.displayUoM ? 'w-50' : 'w-100'"
|
||||
t-att-value="quantity"
|
||||
t-on-change="this.env.setQuantity"
|
||||
t-on-focus="_onFocus"/>
|
||||
<span class="fst-italic text-muted text-bg-light text-truncate w-50 border border-start-0 py-1" t-if="this.env.displayUoM" t-out="props.uomDisplayName"/>
|
||||
</div>
|
||||
<button class="btn btn-primary border"
|
||||
t-on-click.stop="(ev) => this.env.increaseQuantity()">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<t t-elif="props.warning">
|
||||
<i class="fa fa-warning text-warning" t-att-title="props.warning"/>
|
||||
<span
|
||||
class="text-truncate text-warning px-1"
|
||||
t-att-title="props.warning"
|
||||
t-out="props.warning"/>
|
||||
</t>
|
||||
<div
|
||||
class="ms-auto o_product_catalog_buttons o_product_catalog_cancel_global_click"
|
||||
style="min-width: max-content;">
|
||||
<button t-if="!isInOrder()"
|
||||
t-on-click.stop="() => this.env.addProduct()"
|
||||
class="btn btn-secondary">
|
||||
<i class="fa fa-shopping-cart"/>
|
||||
Add
|
||||
</button>
|
||||
<div t-else="" class="o_tooltip_div_remove" t-att-data-tooltip="this.disabledButtonTooltip">
|
||||
<button t-on-click.stop="this.env.removeProduct"
|
||||
t-att-disabled="disableRemove"
|
||||
class="btn btn-light border">
|
||||
<i class="fa fa-trash"/>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useAutoresize } from "@web/core/utils/autoresize";
|
||||
|
||||
/**
|
||||
* This overriden version of the resizeTextArea method is specificly done for the product_label_section_and_note widget
|
||||
* His necessity is found in the fact that the cell of said widget doesn't contain only the input or textarea to resize
|
||||
* but also another node containing the name of the product if said data is available. This means that the autoresize
|
||||
* method which sets the height of the parent cell should sometimes add an additional row to the parent cell so that
|
||||
* no text overflows
|
||||
*
|
||||
* @param {Ref} ref
|
||||
*/
|
||||
export function useProductAndLabelAutoresize(ref, options = {}) {
|
||||
useAutoresize(ref, {
|
||||
onMounted: productAndLabelResizeTextArea,
|
||||
onResize: productAndLabelResizeTextArea,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function productAndLabelResizeTextArea(textarea, options = {}) {
|
||||
const style = window.getComputedStyle(textarea);
|
||||
if (options.targetParentName) {
|
||||
let target = textarea.parentElement;
|
||||
let shouldContinue = true;
|
||||
while (target && shouldContinue) {
|
||||
const totalParentHeight = Array.from(target.children).reduce((total, child) => {
|
||||
const childHeight = child.style.height || style.lineHeight;
|
||||
return total + parseFloat(childHeight);
|
||||
}, 0);
|
||||
target.style.height = `${totalParentHeight}px`;
|
||||
if (target.getAttribute("name") === options.targetParentName) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
target = target.parentElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
|
||||
import { Component, onMounted, onPatched, onWillUnmount, useEffect, useRef, useState } from "@odoo/owl";
|
||||
import { Many2OneField } from "@web/views/fields/many2one/many2one_field";
|
||||
import { useProductAndLabelAutoresize } from "./product_and_label_autoresize";
|
||||
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
|
||||
import { useInputField } from "@web/views/fields/input_field_hook";
|
||||
|
||||
export const ProductNameAndDescriptionListRendererMixin = {
|
||||
getCellTitle(column, record) {
|
||||
// When using this list renderer, we don't want the product_id cell to have a tooltip with its label.
|
||||
if (this.productColumns.includes(column.name)) {
|
||||
return;
|
||||
}
|
||||
return super.getCellTitle(column, record);
|
||||
},
|
||||
|
||||
getActiveColumns() {
|
||||
let activeColumns = super.getActiveColumns();
|
||||
const productCol = activeColumns.find((col) => this.productColumns.includes(col.name));
|
||||
const labelCol = activeColumns.find((col) => col.name === this.descriptionColumn);
|
||||
|
||||
if (productCol) {
|
||||
if (labelCol) {
|
||||
this.props.list.records.forEach((record) => (record.columnIsProductAndLabel = true));
|
||||
} else {
|
||||
this.props.list.records.forEach((record) => (record.columnIsProductAndLabel = false));
|
||||
}
|
||||
activeColumns = activeColumns.filter((col) => col.name !== this.descriptionColumn);
|
||||
this.titleField = productCol.name;
|
||||
} else {
|
||||
this.titleField = "name";
|
||||
}
|
||||
|
||||
return activeColumns;
|
||||
}
|
||||
};
|
||||
|
||||
export class ProductNameAndDescriptionField extends Component {
|
||||
static components = { Many2One };
|
||||
static props = { ...Many2OneField.props };
|
||||
static template = Many2One.template;
|
||||
|
||||
static descriptionColumn = "";
|
||||
|
||||
setup() {
|
||||
this.isPrintMode = useState({ value: false });
|
||||
this.labelVisibility = useState({ value: false });
|
||||
this.switchToLabel = false;
|
||||
this.columnIsProductAndLabel = useState({ value: this.props.record.columnIsProductAndLabel });
|
||||
this.labelNode = useRef("labelNodeRef");
|
||||
useProductAndLabelAutoresize(this.labelNode, { targetParentName: this.props.name });
|
||||
this.productNode = useRef("productNodeRef");
|
||||
useProductAndLabelAutoresize(this.productNode, { targetParentName: this.props.name });
|
||||
|
||||
this.descriptionColumn = this.constructor.descriptionColumn;
|
||||
useInputField({
|
||||
ref: this.labelNode,
|
||||
fieldName: this.descriptionColumn,
|
||||
getValue: () => this.label,
|
||||
parse: (v) => this.parseLabel(v),
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
this.columnIsProductAndLabel.value = this.props.record.columnIsProductAndLabel;
|
||||
},
|
||||
() => [this.props.record.columnIsProductAndLabel]
|
||||
);
|
||||
|
||||
onPatched(() => {
|
||||
if (this.labelNode.el && this.switchToLabel) {
|
||||
this.switchToLabel = false;
|
||||
this.labelNode.el.focus();
|
||||
}
|
||||
});
|
||||
|
||||
this.onBeforePrint = () => {
|
||||
this.isPrintMode.value = true;
|
||||
};
|
||||
|
||||
this.onAfterPrint = () => {
|
||||
this.isPrintMode.value = false;
|
||||
};
|
||||
|
||||
// The following hooks are used to make a div visible only in the print view. This div is necessary in the
|
||||
// print view in order not to have scroll bars but can't be displayed in the normal view because it adds
|
||||
// an empty line. This is done by switching an attribute to true only during the print view life cycle and
|
||||
// including the said div in a t-if depending on that attribute.
|
||||
onMounted(() => {
|
||||
window.addEventListener("beforeprint", this.onBeforePrint);
|
||||
window.addEventListener("afterprint", this.onAfterPrint);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
window.removeEventListener("beforeprint", this.onBeforePrint);
|
||||
window.removeEventListener("afterprint", this.onAfterPrint);
|
||||
});
|
||||
}
|
||||
|
||||
get productName() {
|
||||
return this.props.record.data[this.props.name].display_name || "";
|
||||
}
|
||||
|
||||
get label() {
|
||||
let label = this.props.record.data[this.descriptionColumn];
|
||||
if (label.includes(this.productName)) {
|
||||
label = label.replace(this.productName, "");
|
||||
}
|
||||
return label.trim();
|
||||
}
|
||||
|
||||
get m2oProps() {
|
||||
const p = computeM2OProps(this.props);
|
||||
let value = p.value && { ...p.value };
|
||||
if (this.props.readonly && this.productName) {
|
||||
value = { ...value, display_name: this.productName };
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
canOpen: !this.props.readonly || this.isProductClickable,
|
||||
placeholder: _t("Search a product"),
|
||||
preventMemoization: true,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
get isProductClickable() {
|
||||
return this.props.record.evalContext.parent.state !== "draft";
|
||||
}
|
||||
|
||||
get showLabelVisibilityToggler() {
|
||||
return !this.props.readonly && this.columnIsProductAndLabel.value && !this.label;
|
||||
}
|
||||
|
||||
switchLabelVisibility() {
|
||||
this.labelVisibility.value = !this.labelVisibility.value;
|
||||
this.switchToLabel = true;
|
||||
}
|
||||
|
||||
parseLabel(value) {
|
||||
return value || this.productName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
onM2oInputKeydown(ev) {
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
if (hotkey === "enter" && this.showLabelVisibilityToggler) {
|
||||
this.switchLabelVisibility();
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
3
frontend/product/static/src/scss/product_form.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.oe_title .o_favorite i.fa {
|
||||
font-size: inherit;
|
||||
}
|
||||
83
frontend/product/static/src/scss/report_label_sheet.scss
Normal file
@@ -0,0 +1,83 @@
|
||||
.o_label_sheet {
|
||||
margin-left: -4mm;
|
||||
margin-right: -4mm;
|
||||
overflow: hidden;
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
page-break-before: always;
|
||||
&.o_label_dymo {
|
||||
font-size:90%;
|
||||
width: 57mm;
|
||||
height: 32mm;
|
||||
}
|
||||
div {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
div.o_label_small_text {
|
||||
font-size: 60%;
|
||||
line-height: 130%;
|
||||
}
|
||||
div.o_label_name {
|
||||
background-color: ghostwhite;
|
||||
height: 3em;
|
||||
overflow: hidden;
|
||||
}
|
||||
div.o_label_full {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
}
|
||||
div.o_label_left_column {
|
||||
float: left;
|
||||
font-size: .6em;
|
||||
overflow:hidden;
|
||||
width: 40%;
|
||||
&.o_label_full_with {
|
||||
width: 100%
|
||||
}
|
||||
}
|
||||
div.o_label_right_column {
|
||||
float: right;
|
||||
}
|
||||
div.o_label_small_barcode {
|
||||
font-size: .6em;
|
||||
padding: 0 4px;
|
||||
line-height: normal;
|
||||
}
|
||||
strong.o_label_price {
|
||||
font-size: 2em;
|
||||
}
|
||||
strong.o_label_price_medium {
|
||||
font-size: 1.3em;
|
||||
line-height: normal;
|
||||
padding: 0;
|
||||
padding-right: 2mm;
|
||||
}
|
||||
strong.o_label_price_small {
|
||||
font-size: 0.9em;
|
||||
padding: 0 4px;
|
||||
padding-right: 2mm;
|
||||
}
|
||||
div.o_label_extra_data {
|
||||
overflow: hidden;
|
||||
height: 2.5em;
|
||||
padding: 0;
|
||||
.img {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
div.o_label_clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
// generic 4x12 label w/ all same size font
|
||||
div.o_label_4x12 {
|
||||
padding:0;
|
||||
line-height:1;
|
||||
font-size:55%;
|
||||
overflow:hidden;
|
||||
white-space:nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { models } from '@web/../tests/web_test_helpers';
|
||||
import { ProductProduct as ProductModel } from './product_product';
|
||||
|
||||
export class ProductProduct extends ProductModel {
|
||||
_records = [
|
||||
{ id: 1, name: "Black chair", type: 'goods', list_price: 50.0 },
|
||||
{ id: 2, name: "Blue chair", type: 'goods', list_price: 60.0 },
|
||||
{ id: 3, name: "Black table", type: 'goods', list_price: 70.0 },
|
||||
{ id: 4, name: "Blue table", type: 'goods', list_price: 80.0 },
|
||||
{ id: 5, name: "Test Combo", type: 'combo', combo_ids: [1, 2] },
|
||||
];
|
||||
}
|
||||
|
||||
export class ProductComboItem extends models.ServerModel {
|
||||
_name = 'product.combo.item';
|
||||
_records = [
|
||||
{ id: 1, product_id: 1 },
|
||||
{ id: 2, product_id: 2 },
|
||||
{ id: 3, product_id: 3 },
|
||||
{ id: 4, product_id: 4 },
|
||||
];
|
||||
}
|
||||
|
||||
export class ProductCombo extends models.ServerModel {
|
||||
_name = 'product.combo';
|
||||
_records = [
|
||||
{ id: 1, name: "Chair combo", combo_item_ids: [1, 2] },
|
||||
{ id: 2, name: "Table combo", combo_item_ids: [3, 4] },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
|
||||
export class ProductProduct extends models.ServerModel {
|
||||
_name = "product.product";
|
||||
|
||||
_records = [
|
||||
{id: 1, name: "Test Product", type: "consu", list_price: 20.0},
|
||||
{id: 2, name: "Test Service Product", type: "service", list_price: 50.0},
|
||||
{id: 14, name: "desk"},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
|
||||
export class ProductTemplate extends models.ServerModel {
|
||||
_name = "product.template";
|
||||
|
||||
get_single_product_variant() {
|
||||
return { product_id: 14, product_name: "desk" };
|
||||
}
|
||||
|
||||
_records = [{ id: 12, name: "desk" }];
|
||||
}
|
||||
16
frontend/product/static/tests/product_combo_test_helpers.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineModels } from '@web/../tests/web_test_helpers';
|
||||
import {
|
||||
ProductCombo,
|
||||
ProductComboItem,
|
||||
ProductProduct,
|
||||
} from './mock_server/mock_models/product_combo';
|
||||
|
||||
export const comboModels = {
|
||||
ProductCombo,
|
||||
ProductComboItem,
|
||||
ProductProduct,
|
||||
}
|
||||
|
||||
export function defineComboModels() {
|
||||
defineModels(comboModels);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { expect, test } from "@odoo/hoot";
|
||||
import { queryAllTexts } from "@odoo/hoot-dom";
|
||||
import { contains, defineModels, fields, getService, models, mountWebClient, onRpc } from "@web/../tests/web_test_helpers";
|
||||
|
||||
class ProductProduct extends models.Model {
|
||||
_records = [{ id: 42, name: "Customizable Desk" }];
|
||||
|
||||
name = fields.Char();
|
||||
}
|
||||
|
||||
class ProductPricelist extends models.Model {
|
||||
_records = [
|
||||
{ id: 1, name: "Public Pricelist" },
|
||||
{ id: 2, name: "Test" },
|
||||
];
|
||||
|
||||
name = fields.Char();
|
||||
}
|
||||
|
||||
defineModels([ProductProduct, ProductPricelist]);
|
||||
defineMailModels();
|
||||
|
||||
test(`Pricelist Client Action`, async () => {
|
||||
onRpc("report.product.report_pricelist", "get_html", async () => "");
|
||||
|
||||
await mountWebClient();
|
||||
await getService("action").doAction({
|
||||
id: 1,
|
||||
name: "Generate Pricelist Report",
|
||||
tag: "generate_pricelist_report",
|
||||
type: "ir.actions.client",
|
||||
context: {
|
||||
active_ids: [42],
|
||||
active_model: "product.product",
|
||||
},
|
||||
});
|
||||
|
||||
// checking default pricelist
|
||||
expect(`select#pricelists > option:eq(0)`).toHaveText("Public Pricelist", {
|
||||
message: "should have default pricelist",
|
||||
});
|
||||
|
||||
// changing pricelist
|
||||
await contains(`select#pricelists`).select("2");
|
||||
|
||||
// check whether pricelist value has been updated or not
|
||||
expect(`select#pricelists > option:eq(0)`).toHaveText("Test", {
|
||||
message: "After pricelist change, the pricelist_id field should be updated",
|
||||
});
|
||||
|
||||
// check default quantities should be there
|
||||
expect(queryAllTexts(`.o_badges_list .badge`)).toEqual(["1", "5", "10"]);
|
||||
|
||||
// existing quantity can not be added.
|
||||
await contains(`.o_add_qty`).click();
|
||||
expect(queryAllTexts(`.o_badges_list .badge`)).toEqual(["1", "5", "10"]);
|
||||
expect(`.o_notification`).toHaveCount(1);
|
||||
expect(`.o_notification .o_notification_content`).toHaveText(
|
||||
"Quantity already present (1).",
|
||||
{ message: "Existing Quantity can not be added" }
|
||||
);
|
||||
expect(`.o_notification .o_notification_bar`).toHaveClass("bg-info");
|
||||
await contains(`.o_notification_close`).click();
|
||||
expect(`.o_notification`).toHaveCount(0);
|
||||
|
||||
// adding few more quantities to check.
|
||||
await contains(`.add-quantity-input`).edit("2", { confirm: false });
|
||||
await contains(`.o_add_qty`).click();
|
||||
expect(queryAllTexts(`.o_badges_list .badge`)).toEqual(["1", "2", "5", "10"]);
|
||||
expect(`.o_notification`).toHaveCount(0);
|
||||
|
||||
await contains(`.add-quantity-input`).edit("3", { confirm: false });
|
||||
await contains(`.o_add_qty`).click();
|
||||
expect(queryAllTexts(`.o_badges_list .badge`)).toEqual(["1", "2", "3", "5", "10"]);
|
||||
expect(`.o_notification`).toHaveCount(0);
|
||||
|
||||
// no more than 5 quantities can be used at a time
|
||||
await contains(`.add-quantity-input`).edit("4", { confirm: false });
|
||||
await contains(`.o_add_qty`).click();
|
||||
expect(queryAllTexts(`.o_badges_list .badge`)).toEqual(["1", "2", "3", "5", "10"]);
|
||||
expect(`.o_notification`).toHaveCount(1);
|
||||
expect(`.o_notification .o_notification_content`).toHaveText(
|
||||
"At most 5 quantities can be displayed simultaneously. Remove a selected quantity to add others.",
|
||||
{ message: "Can not add more then 5 quantities" }
|
||||
);
|
||||
expect(`.o_notification .o_notification_bar`).toHaveClass("bg-warning");
|
||||
});
|
||||
13
frontend/product/static/tests/product_test_helpers.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineModels } from '@web/../tests/web_test_helpers';
|
||||
import { ProductProduct } from './mock_server/mock_models/product_product';
|
||||
import { ProductTemplate } from './mock_server/mock_models/product_template';
|
||||
|
||||
|
||||
export const productModels = {
|
||||
ProductProduct,
|
||||
ProductTemplate,
|
||||
};
|
||||
|
||||
export function defineProductModels() {
|
||||
defineModels(productModels);
|
||||
}
|
||||