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>
This commit is contained in:
Marc
2026-03-31 23:09:12 +02:00
parent 0ed29fe2fd
commit 8741282322
2933 changed files with 280644 additions and 264 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -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);

View File

@@ -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>

View File

@@ -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);

View File

@@ -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,
};
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
.o_kanban_previewer:hover {
cursor: pointer;
}

View File

@@ -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' didnt upload since its format isnt 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);
}
}
}

View File

@@ -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>

View File

@@ -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,
});
}
}
}

View File

@@ -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>

View 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;
}
}

View 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));
}
}

View File

@@ -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>

View File

@@ -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(),
}
);
}
}

View File

@@ -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>

View 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);

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,3 @@
.oe_title .o_favorite i.fa {
font-size: inherit;
}

View 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;
}
}

View File

@@ -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] },
];
}

View File

@@ -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"},
];
}

View File

@@ -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" }];
}

View 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);
}

View File

@@ -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");
});

View 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);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.