Odoo OWL Framework - extend and customize Component and Widget
Tags: odoo, webdev
Published: 2024-06-06 10:05:53

Problem : When a user selects a product in the product field of the order line in a new sale order form, the system should check if this product, associated with the same customer, already exists in any sale orders that are in the confirmed stage. If such a product is found, a popup warning with some action buttons should be displayed.

While one might consider using the onchange method in the Odoo backend to achieve this, the onchange API in Odoo does not support triggering popup wizards. Therefore, we need to use the OWL (Odoo Web Library) framework to perform this check and trigger the popup.

To implement this solution, we will extend the existing component, use RPC (Remote Procedure Call), and ORM (Object-Relational Mapping) API to access the database in the backend and pass the necessary values to the frontend.

 

Solution:
if you check in the product Field in sale order line you can see there is a widget - sol_product_many2one , so we need to extend this widget and add our custom logic 

Identify the Existing Product Field:
        Locate the existing product field in Odoo: odoo/addons/sale/static/src/js/sale_product_field.js.
		
Create a New JS File:

    In your custom module, create a new JS file under custom_module/src/components.

    Import the existing field as follows:
 
 

`/** @odoo-module */

import { registry } from "@web/core/registry";
import { many2OneField } from '@web/views/fields/many2one/many2one_field';
import { Component } from "@odoo/owl";
import { jsonrpc } from "@web/core/network/rpc_service";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { Dialog } from "@web/core/dialog/dialog";

import  {  SaleOrderLineProductField } from '@sale/js/sale_product_field'`
 
 
important : do not forget to add - /** @odoo-module */ - on top of the file unless it will throw error 


Create a Component:

    Create a new component and extend from the existing product field.



```
// Define a class DuplicateProductDialog that extends the Component class
export class DuplicateProductDialog extends Component {
    // Define the components used in this class, in this case, Dialog
    static components = { Dialog };

    // Define the properties (props) for the component
    static props = {
        close: Function,          // Function to close the dialog
        title: String,            // Title of the dialog
        orders: Array,            // Array of orders to be displayed
        onAddProduct: Function,   // Function to handle adding a product
        onRemoveProduct: Function // Function to handle removing a product
    };

    // Define the template for this component
    static template = "custom_module.DuplicateProductDialog";

    // Setup method to initialize the class
    setup() {
        // Set the title of the dialog from the props
        this.title = this.props.title;            
    } 
 
    /**
     * Public method to handle adding a product
     * @public
     * @param {number} orderId - The ID of the order to which the product will be added
     */
    addProduct(orderId) {
        // Call the onAddProduct function passed in the props
        this.props.onAddProduct();
        // Close the dialog
        this.props.close();
    }

    /**
     * Public method to handle removing a product
     * @public
     */
    removeProduct() {
        // Call the onRemoveProduct function passed in the props
        this.props.onRemoveProduct();       
    }
}
```




So we are going to replace the existing productField widget 

```
// Define a class SaleOrderproductField that extends the SaleOrderLineProductField class
export class SaleOrderproductField extends SaleOrderLineProductField {
    
    // Setup method to initialize the class
    setup() {
        // Call the setup method of the parent class
        super.setup();
        // Initialize the dialog service
        this.dialog = useService("dialog");       
    }
 
    // Asynchronous method to update the record with the provided value
    async updateRecord(value) {
        // Call the updateRecord method of the parent class
        super.updateRecord(value);
        // Check for duplicate products after updating the record
        const is_duplicate = await this._onCheckproductUpdate(value);
    }
 
    // Asynchronous method to check for duplicate products in the sale order
    async _onCheckproductUpdate(product) {     
        const partnerId = this.context.partner_id; // Get the partner ID from the context
        const customerName = document.getElementsByName("partner_id")[0].querySelector(".o-autocomplete--input").value; // Get the customer name from the input field
        const productId = product[0]; // Get the product ID from the product array
        
        // Check if the customer name is not provided
        if (!customerName) {
            alert("Please Choose Customer"); // Alert the user to choose a customer
            return true; // Return true indicating a duplicate product scenario
        }

        // Fetch sale order lines that match the given criteria
        const saleOrderLines = await jsonrpc("/web/dataset/call_kw/sale.order.line/search_read", {
            model: 'sale.order.line',
            method: "search_read",
            args:  [
                [
                    ["order_partner_id", "=", partnerId],
                    ["product_template_id", "=", productId],
                    ["state", "=", "sale"]
                ]
            ],
            kwargs: {
                fields: ['id', 'product_uom_qty', 'order_id', 'move_ids', 'name', 'state'],
                order: "name"
            }
        });

        const reservedOrders = []; // Array to hold reserved orders
        let stockMoves = []; // Array to hold stock moves

        // Check if any sale order lines are found
        if (saleOrderLines.length > 0) {
            // Iterate through each sale order line
            for (const line of saleOrderLines) {
                // Fetch stock moves associated with the sale order line
                const stockMoves = await jsonrpc("/web/dataset/call_kw/stock.move/search_read", {
                    model: 'stock.move',
                    method: "search_read",
                    args: [[
                        ['sale_line_id', '=', line.id],
                    ]],
                    kwargs: {
                        fields: ['name', 'state']
                    }
                });

                // Check if any stock moves are found
                if (stockMoves.length > 0) {
                    // Add the order details to the reserved orders array
                    reservedOrders.push({
                        order_number: line['order_id'][1], // Order number
                        order_id: line['order_id'][0], // Order ID
                        product_info: line['name'], // Product information
                        product_qty: line['product_uom_qty'] // Product quantity
                    });
                }               
            }        
        }

        // Check if there are any reserved orders
        if (reservedOrders.length > 0) {
            // Show a dialog with duplicate product warning
            this.dialog.add(DuplicateProductDialog, {
                title: _t("Warning For %s", product[1]), // Warning title with product name
                orders: reservedOrders, // List of reserved orders
                onAddProduct: async (product) => {
                    return true; // Callback for adding product
                },
                onRemoveProduct: async (product) => {
                    const currentRow = document.getElementsByClassName('o_data_row o_selected_row o_row_draggable o_is_false')[0]; // Get the currently selected row
                    if (currentRow) {
                        currentRow.remove(); // Remove the current row
                    }
                },
            });
            return true; // Return true indicating a duplicate product scenario
        } else {
            return false; // Return false indicating no duplicate products found
        }
    }
}

```
once we have this, we need to export this 

```
SaleOrderproductField.template = "web.Many2OneField";

export const saleOrderproductField = {
    ...many2OneField,
    component: SaleOrderproductField,
};

registry.category("fields").add("so_product_many2one", saleOrderproductField);
```

Integrate the New Widget:

    Attach this new widget to the inherited sale order form as shown below:


    <xpath expr="//field[@name='product_template_id']" position="attributes">
                    <attribute name="widget">so_product_many2one</attribute>
    </xpath>
	
	
Create a Popup Wizard View:

    Create a popup wizard view and define the required props (title and orders) inside the component to avoid errors in debug mode.

```
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
	<t t-name="custom_module.DuplicateProductDialog">
		<Dialog size="'md'" title="title" modalRef="modalRef">
			<table class="table">
				<thead>
				</thead>
				<tbody>
					<t t-foreach="this.props.orders" t-as="item" t-key="item.order_id">
						<div class="d-flex align-items-start flex-column mb-3">
							<tr>
								<td>
									<p>
										The product
										<t t-out="item.product_info" />
										is already reserved for this customer under order number
										<span t-esc="item.order_number" />
										with a quantity of
										<strong>
											<t t-out="item.product_qty" />
										</strong>
										. Please confirm if you still want to add this line item to the order
										<button class="btn btn-primary me-1" t-on-click="() => this.addProduct(item.id)">
											Add
										</button>
										<button class="btn btn-primary ms-1" t-on-click="() => this.removeProduct(item.id)">
											Remove
										</button>
									</p>
								</td>
							</tr>
						</div>
					</t>
				</tbody>
			</table>
		</Dialog>
	</t>
</templates>

```

So we passed the props title, orders. Important to define these props inside Component 

```

export class DuplicateProductDialog extends Component {
    static components = { Dialog };
    static props = {
        close: Function,
        title: String,
        orders: Array,
        onAddProduct: Function,
        onRemoveProduct: Function, 
   };
```
   
otherwise, thise will throw an error as below on debug mode = 1 situation 

OwlError: Invalid props for component  ( https://www.odoo.com/forum/help-1/owlerror-invalid-props-for-component-taxgroupcomponent-currency-is-undefined-should-be-a-value-213238 ) 


So once we have everything, restart Odoo, upgrade module and try to create some Sale Orders with a particular customer with same product. The confirm one or two sale orders and try to create a new sale order for same customer and choose same product, then it should trigger a popup warning window. 

OWL framework is a very important part of Odoo framework, but lack of proper documentation is a hurdle for Odoo developers, hope this could be a simple help. 


wishes 

full code for JS 

```

/** @odoo-module */

import { registry } from "@web/core/registry";
import { many2OneField } from '@web/views/fields/many2one/many2one_field';
import { Component } from "@odoo/owl";
import { jsonrpc } from "@web/core/network/rpc_service";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { Dialog } from "@web/core/dialog/dialog";
import  {  SaleOrderLineProductField } from '@sale/js/sale_product_field'
 

export class DuplicateProductDialog extends Component {
    static components = { Dialog };
    static props = {
        close: Function,
        title: String,
        orders: Array,
        onAddProduct: Function,
        onRemoveProduct: Function, 
   };
    static template = "custom_module.DuplicateProductDialog";
    setup() {
        this.title = this.props.title;            
    } 
 
   
    /**
     * @public
     */
    addProduct(orderId) {
        this.props.onAddProduct();
        this.props.close();
    }
    removeProduct() {   
        this.props.onRemoveProduct();       
    }
}

export class SaleOrderproductField extends SaleOrderLineProductField {
    setup() {
        super.setup();
        this.dialog = useService("dialog");       
    }
 
    async updateRecord (value){
        super.updateRecord(value);
        const is_duplicate = await this._onCheckproductUpdate(value);
    }
 
    async _onCheckproductUpdate(product) {     
        const partnerId = this.context.partner_id
        const customerName = document.getElementsByName("partner_id")[0].querySelector(".o-autocomplete--input").value;
        const productId = product[0];
        if (!customerName ) {
            alert("Please Choose Customer")
            return true; 
        }

        const saleOrderLines = await jsonrpc("/web/dataset/call_kw/sale.order.line/search_read", {
            model: 'sale.order.line',
            method: "search_read",
            args:  [
                [
                    ["order_partner_id", "=", partnerId],
                    ["product_template_id", "=", productId],
                    ["state","=","sale"]
                ]
            ],
            kwargs: {
                fields: ['id','product_uom_qty', 'order_id', 'move_ids', 'name', 'state'],
                order: "name"
            }
        });

        const reservedOrders = [];
        let stockMoves = [];
        if(saleOrderLines.length > 0){
            for (const line of saleOrderLines) {
                 const stockMoves = await jsonrpc("/web/dataset/call_kw/stock.move/search_read", {
                    model: 'stock.move',
                    method: "search_read",
                    args: [[
                        ['sale_line_id', '=', line.id],
                         
                    ]],
                    kwargs: {
                        fields: ['name', 'state']
                    }
                });
                
                if (stockMoves.length > 0) {
                    reservedOrders.push({
                        order_number: line['order_id'][1],                       
                        order_id: line['order_id'][0],
                        product_info:line['name'],
                        product_qty: line['product_uom_qty']
                    });
                }               
            }        
        }

        if (reservedOrders.length > 0) {
            this.dialog.add(DuplicateProductDialog, {
                title: _t("Warning For %s", product[1]),
                orders: reservedOrders,      
                onAddProduct: async (product) => {
                    return true;
                },
                onRemoveProduct: async (product) => {
                    const currentRow = document.getElementsByClassName('o_data_row o_selected_row o_row_draggable o_is_false')[0]
                     if(currentRow){
                        currentRow.remove();                     
                     }    
                },
            });
            return true;
        } else {
            return false;
        }
    }
}
SaleOrderproductField.template = "web.Many2OneField";

export const saleOrderproductField = {
    ...many2OneField,
    component: SaleOrderproductField,
};

registry.category("fields").add("so_product_many2one", saleOrderproductField);
```