[REF] sale_product_configurator: convert configurator to OWL
Task-2918791 X-original-commit: ab3e2c9b129e2cf74e9341b49bc0d15fb971ea33 Part-of: odoo/odoo#101352
This commit is contained in:
@@ -25,11 +25,8 @@ It also enables the "optional products" feature.
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'sale/static/src/js/variant_mixin.js',
|
||||
'sale_product_configurator/static/src/js/product_configurator_widget.js',
|
||||
'sale_product_configurator/static/src/js/product_configurator_modal.js',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'sale_product_configurator/static/tests/product_configurator.test.js',
|
||||
'sale_product_configurator/static/src/js/sale_product_field.js',
|
||||
],
|
||||
},
|
||||
'auto_install': True,
|
||||
|
||||
@@ -21,12 +21,15 @@ class ProductConfiguratorController(http.Controller):
|
||||
if pricelist:
|
||||
product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id)
|
||||
|
||||
return request.env['ir.ui.view']._render_template("sale_product_configurator.configure", {
|
||||
'product': product_template,
|
||||
'pricelist': pricelist,
|
||||
'add_qty': add_qty,
|
||||
'product_combination': product_combination
|
||||
})
|
||||
return request.env['ir.ui.view']._render_template(
|
||||
"sale_product_configurator.configure",
|
||||
{
|
||||
'product': product_template,
|
||||
'pricelist': pricelist,
|
||||
'add_qty': add_qty,
|
||||
'product_combination': product_combination
|
||||
},
|
||||
)
|
||||
|
||||
@http.route(['/sale_product_configurator/show_advanced_configurator'], type='json', auth="user", methods=['POST'])
|
||||
def show_advanced_configurator(self, product_id, variant_values, pricelist_id, **kw):
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
/** @odoo-module */
|
||||
import ProductConfiguratorWidget from "sale.product_configurator";
|
||||
import { OptionalProductsModal } from "@sale_product_configurator/js/product_configurator_modal";
|
||||
import {
|
||||
selectOrCreateProduct,
|
||||
getSelectedVariantValues,
|
||||
getNoVariantAttributeValues,
|
||||
} from "sale.VariantMixin";
|
||||
import { _t } from "web.core";
|
||||
|
||||
/**
|
||||
* Extension of the ProductConfiguratorWidget to support product configuration.
|
||||
* It opens when a configurable product_template is set.
|
||||
* (multiple variants, or custom attributes)
|
||||
*
|
||||
* The product customization information includes :
|
||||
* - is_configurable_product
|
||||
* - product_template_attribute_value_ids
|
||||
*
|
||||
*/
|
||||
ProductConfiguratorWidget.include({
|
||||
/**
|
||||
* Override of sale.product_configurator Hook
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
_isConfigurableProduct: function () {
|
||||
return this.recordData.is_configurable_product || this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set restoreProductTemplateId for further backtrack.
|
||||
* Saves the optional products in the widget for future application
|
||||
* post-line configuration.
|
||||
*
|
||||
* {OdooEvent ev}
|
||||
* {Array} ev.data.optionalProducts the various selected optional products
|
||||
* with their configuration
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
reset: function (record, ev) {
|
||||
if (ev && ev.target === this) {
|
||||
this.restoreProductTemplateId = this.recordData.product_template_id;
|
||||
this.optionalProducts = (ev.data && ev.data.optionalProducts) || this.optionalProducts;
|
||||
}
|
||||
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
/**
|
||||
* This method is overridden to check if the product_template_id
|
||||
* needs configuration or not:
|
||||
*
|
||||
* - The product_template has only one "product.product" and is not dynamic
|
||||
* -> Set the product_id on the SO line
|
||||
* -> If the product has optional products, open the configurator in 'options' mode
|
||||
*
|
||||
* - The product_template is configurable
|
||||
* -> Open the product configurator wizard and initialize it with
|
||||
* the provided product_template_id and its current attribute values
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onTemplateChange: function (productTemplateId, dataPointId) {
|
||||
var self = this;
|
||||
var ctx = {};
|
||||
if (this.record && this.recordParams) {
|
||||
ctx = this.record.getContext(this.recordParams);
|
||||
}
|
||||
|
||||
return this._rpc({
|
||||
model: "product.template",
|
||||
method: "get_single_product_variant",
|
||||
args: [productTemplateId],
|
||||
context: ctx,
|
||||
}).then(function (result) {
|
||||
if (result.product_id && !result.has_optional_products) {
|
||||
self.trigger_up("field_changed", {
|
||||
dataPointID: dataPointId,
|
||||
changes: {
|
||||
product_id: { id: result.product_id },
|
||||
product_custom_attribute_value_ids: {
|
||||
operation: "DELETE_ALL",
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return self._openConfigurator(result, productTemplateId, dataPointId);
|
||||
}
|
||||
// always returns true for the moment because no other configurator exists.
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* When line is configured, apply the options defined earlier.
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onLineConfigured: function () {
|
||||
var self = this;
|
||||
this._super.apply(this, arguments);
|
||||
var parentList = self.getParent();
|
||||
var unselectRow = (parentList.unselectRow || function () {}).bind(parentList); // form view on mobile
|
||||
if (self.optionalProducts && self.optionalProducts.length !== 0) {
|
||||
self.trigger_up("add_record", {
|
||||
context: self._productsToRecords(self.optionalProducts),
|
||||
forceEditable: "bottom",
|
||||
allowWarning: true,
|
||||
onSuccess: function () {
|
||||
// Leave edit mode of one2many list.
|
||||
unselectRow();
|
||||
},
|
||||
});
|
||||
} else if (!self._isConfigurableLine() && self._isConfigurableProduct()) {
|
||||
// Leave edit mode of current line if line was configured
|
||||
// only through the product configurator.
|
||||
unselectRow();
|
||||
}
|
||||
},
|
||||
|
||||
_openConfigurator: function (result, productTemplateId, dataPointId) {
|
||||
if (!result.mode || result.mode === "configurator") {
|
||||
this._openProductConfigurator(
|
||||
{
|
||||
configuratorMode: result && result.has_optional_products ? "options" : "add",
|
||||
pricelist_id: this._getPricelistId(),
|
||||
product_template_id: productTemplateId,
|
||||
},
|
||||
dataPointId
|
||||
);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the product configurator to allow configuring the product template
|
||||
* and its various options.
|
||||
*
|
||||
* The configuratorMode param controls how to open the configurator.
|
||||
* - The "add" mode will allow configuring the product template & options.
|
||||
* - The "edit" mode will only allow editing the product template's configuration.
|
||||
* - The "options" mode is a special case where the product configurator is used as a bridge
|
||||
* between the SO line and the optional products modal. It will hide its window and handle
|
||||
* the communication between those two.
|
||||
*
|
||||
* When the configuration is canceled (i.e when the product configurator is closed using the
|
||||
* "CANCEL" button or the cross on the top right corner of the window),
|
||||
* the product_template is reset to its previous value if any.
|
||||
*
|
||||
* @param {Object} data various "default_" values
|
||||
* {string} data.configuratorMode 'add' or 'edit' or 'options'.
|
||||
* @param {string} dataPointId
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _openProductConfigurator(data, dataPointId) {
|
||||
this.optionalProducts = undefined;
|
||||
const optionalProductsModal = await this._openModal(data);
|
||||
// no optional products found for this product, only add the root product
|
||||
optionalProductsModal.on("options_empty", null, () =>
|
||||
this._addProducts({ mainProduct: this.rootProduct }, dataPointId)
|
||||
);
|
||||
|
||||
let confirmed = false;
|
||||
optionalProductsModal.on("confirm", null, async () => {
|
||||
confirmed = true;
|
||||
const [
|
||||
mainProduct,
|
||||
...options
|
||||
] = await optionalProductsModal.getAndCreateSelectedProducts();
|
||||
this._addProducts({ mainProduct, options }, dataPointId);
|
||||
});
|
||||
optionalProductsModal.on("closed", null, () => {
|
||||
if (confirmed) {
|
||||
return;
|
||||
}
|
||||
if (this.restoreProductTemplateId) {
|
||||
// if configurator opened in edit mode.
|
||||
this.trigger_up("field_changed", {
|
||||
dataPointID: dataPointId,
|
||||
preventProductIdCheck: true,
|
||||
changes: {
|
||||
product_template_id: this.restoreProductTemplateId.data,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// if configurator opened to create line:
|
||||
// destroy line if configurator closed during configuration process.
|
||||
this.trigger_up("field_changed", {
|
||||
dataPointID: dataPointId,
|
||||
changes: {
|
||||
product_template_id: false,
|
||||
product_id: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async _openModal(data) {
|
||||
const {
|
||||
product_template_id,
|
||||
product_template_attribute_value_ids,
|
||||
product_no_variant_attribute_value_ids,
|
||||
pricelist_id,
|
||||
product_custom_attribute_value_ids,
|
||||
quantity,
|
||||
configuratorMode,
|
||||
} = data;
|
||||
// FIXME replace this with an RPC instead of rendering a template and querying it for data
|
||||
const $modal = $(
|
||||
await this._rpc({
|
||||
route: "/sale_product_configurator/configure",
|
||||
params: {
|
||||
product_template_id,
|
||||
pricelist_id,
|
||||
add_qty: quantity,
|
||||
product_template_attribute_value_ids,
|
||||
product_no_variant_attribute_value_ids,
|
||||
context: this.getSession().user_context,
|
||||
},
|
||||
})
|
||||
);
|
||||
const productSelector = `input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked`;
|
||||
const productTemplateId = $modal.find(".product_template_id").val();
|
||||
const productId = await selectOrCreateProduct.call(
|
||||
this,
|
||||
$modal,
|
||||
parseInt($modal.find(productSelector).first().val(), 10),
|
||||
productTemplateId,
|
||||
false
|
||||
);
|
||||
$modal.find(productSelector).val(productId);
|
||||
const variantValues = getSelectedVariantValues($modal);
|
||||
const noVariantAttributeValues = getNoVariantAttributeValues($modal);
|
||||
this.rootProduct = {
|
||||
product_id: productId,
|
||||
product_template_id: parseInt(productTemplateId),
|
||||
quantity: parseFloat($modal.find('input[name="add_qty"]').val() || 1),
|
||||
variant_values: variantValues,
|
||||
product_custom_attribute_values: product_custom_attribute_value_ids,
|
||||
no_variant_attribute_values: noVariantAttributeValues,
|
||||
};
|
||||
return new OptionalProductsModal(null, {
|
||||
rootProduct: this.rootProduct,
|
||||
pricelistId: pricelist_id,
|
||||
okButtonText: _t("Confirm"),
|
||||
cancelButtonText: _t("Back"),
|
||||
title: _t("Configure"),
|
||||
context:
|
||||
(this.record && this.recordParams && this.record.getContext(this.recordParams)) ||
|
||||
{},
|
||||
previousModalHeight: this.$el.closest(".modal-content").height(),
|
||||
mode: configuratorMode,
|
||||
}).open();
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the product configurator in "edit" mode.
|
||||
* (see '_openProductConfigurator' for more info on the "edit" mode).
|
||||
* The requires to retrieve all the needed data from the SO line
|
||||
* that are kept in the "recordData" object.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEditProductConfiguration: function () {
|
||||
if (!this.recordData.is_configurable_product) {
|
||||
// if line should be edited by another configurator
|
||||
// or simply inline.
|
||||
this._super.apply(this, arguments);
|
||||
return;
|
||||
}
|
||||
this.restoreProductTemplateId = this.recordData.product_template_id;
|
||||
// If line has been set up through the product_configurator:
|
||||
this._openProductConfigurator(
|
||||
{
|
||||
configuratorMode: "edit",
|
||||
product_template_id: this.recordData.product_template_id.data.id,
|
||||
pricelist_id: this._getPricelistId(),
|
||||
product_template_attribute_value_ids: this._toIdArray(
|
||||
this.recordData.product_template_attribute_value_ids
|
||||
),
|
||||
product_no_variant_attribute_value_ids: this._toIdArray(
|
||||
this.recordData.product_no_variant_attribute_value_ids
|
||||
),
|
||||
product_custom_attribute_value_ids: this.recordData.product_custom_attribute_value_ids.data.map(
|
||||
(d) => d.data
|
||||
),
|
||||
quantity: this.recordData.product_uom_qty,
|
||||
},
|
||||
this.dataPointID
|
||||
);
|
||||
},
|
||||
|
||||
_toIdArray(recordData) {
|
||||
return recordData ? [...recordData.res_ids] : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* This will first modify the SO line to update all the information coming from
|
||||
* the product configurator using the 'field_changed' event.
|
||||
*
|
||||
* onSuccess from that first method, it will add the optional products to the SO
|
||||
* using the 'add_record' event.
|
||||
*
|
||||
* Doing both at the same time could lead to unordered product_template/options.
|
||||
*
|
||||
* @param {Object} products the products to add to the SO line.
|
||||
* {Object} products.mainProduct the product_template configured
|
||||
* with various attribute/custom values
|
||||
* {Array} products.options the various selected optional products
|
||||
* with their configuration
|
||||
* @param {string} dataPointId
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_addProducts: function (result, dataPointId) {
|
||||
this.trigger_up("field_changed", {
|
||||
dataPointID: dataPointId,
|
||||
preventProductIdCheck: true,
|
||||
optionalProducts: result.options,
|
||||
changes: this._getMainProductChanges(result.mainProduct),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* This will convert the result of the product configurator into
|
||||
* "changes" that are understood by the basic_model.js
|
||||
*
|
||||
* For the product_custom_attribute_value_ids, we need to do a DELETE_ALL
|
||||
* command to clean the currently selected values and then a CREATE for every
|
||||
* custom value specified in the configurator.
|
||||
*
|
||||
* For the product_no_variant_attribute_value_ids, we also need to do a DELETE_ALL
|
||||
* command to clean the currently selected values and issue a single ADD_M2M containing
|
||||
* all the ids of the product_attribute_values.
|
||||
*
|
||||
* @param {Object} mainProduct
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_getMainProductChanges: function (mainProduct) {
|
||||
var result = {
|
||||
product_id: { id: mainProduct.product_id },
|
||||
product_template_id: { id: mainProduct.product_template_id },
|
||||
product_uom_qty: mainProduct.quantity,
|
||||
};
|
||||
|
||||
var customAttributeValues = mainProduct.product_custom_attribute_values;
|
||||
var customValuesCommands = [{ operation: "DELETE_ALL" }];
|
||||
if (customAttributeValues && customAttributeValues.length !== 0) {
|
||||
_.each(customAttributeValues, function (customValue) {
|
||||
// FIXME awa: This could be optimized by adding a "disableDefaultGet" to avoid
|
||||
// having multiple default_get calls that are useless since we already
|
||||
// have all the default values locally.
|
||||
// However, this would mean a lot of changes in basic_model.js to handle
|
||||
// those "default_" values and set them on the various fields (text,o2m,m2m,...).
|
||||
// -> This is not considered as worth it right now.
|
||||
customValuesCommands.push({
|
||||
operation: "CREATE",
|
||||
context: [
|
||||
{
|
||||
default_custom_product_template_attribute_value_id:
|
||||
customValue.custom_product_template_attribute_value_id,
|
||||
default_custom_value: customValue.custom_value,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
result["product_custom_attribute_value_ids"] = {
|
||||
operation: "MULTI",
|
||||
commands: customValuesCommands,
|
||||
};
|
||||
|
||||
var noVariantAttributeValues = mainProduct.no_variant_attribute_values;
|
||||
var noVariantCommands = [{ operation: "DELETE_ALL" }];
|
||||
if (noVariantAttributeValues && noVariantAttributeValues.length !== 0) {
|
||||
var resIds = _.map(noVariantAttributeValues, function (noVariantValue) {
|
||||
return { id: parseInt(noVariantValue.value) };
|
||||
});
|
||||
|
||||
noVariantCommands.push({
|
||||
operation: "ADD_M2M",
|
||||
ids: resIds,
|
||||
});
|
||||
}
|
||||
|
||||
result["product_no_variant_attribute_value_ids"] = {
|
||||
operation: "MULTI",
|
||||
commands: noVariantCommands,
|
||||
};
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the pricelist_id set on the sale_order form
|
||||
*
|
||||
* @private
|
||||
* @returns {integer} pricelist_id's id
|
||||
*/
|
||||
_getPricelistId: function () {
|
||||
return this.record.evalContext.parent.pricelist_id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Will map the products to appropriate record objects that are
|
||||
* ready for the default_get.
|
||||
*
|
||||
* @param {Array} products The products to transform into records
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_productsToRecords: function (products) {
|
||||
var records = [];
|
||||
_.each(products, function (product) {
|
||||
var record = {
|
||||
default_product_id: product.product_id,
|
||||
default_product_template_id: product.product_template_id,
|
||||
default_product_uom_qty: product.quantity,
|
||||
};
|
||||
|
||||
if (product.no_variant_attribute_values) {
|
||||
var defaultProductNoVariantAttributeValues = [];
|
||||
_.each(product.no_variant_attribute_values, function (attributeValue) {
|
||||
defaultProductNoVariantAttributeValues.push([
|
||||
4,
|
||||
parseInt(attributeValue.value),
|
||||
]);
|
||||
});
|
||||
record[
|
||||
"default_product_no_variant_attribute_value_ids"
|
||||
] = defaultProductNoVariantAttributeValues;
|
||||
}
|
||||
|
||||
if (product.product_custom_attribute_values) {
|
||||
var defaultCustomAttributeValues = [];
|
||||
_.each(product.product_custom_attribute_values, function (attributeValue) {
|
||||
defaultCustomAttributeValues.push([
|
||||
0,
|
||||
0,
|
||||
{
|
||||
custom_product_template_attribute_value_id:
|
||||
attributeValue.custom_product_template_attribute_value_id,
|
||||
custom_value: attributeValue.custom_value,
|
||||
},
|
||||
]);
|
||||
});
|
||||
record["default_product_custom_attribute_value_ids"] = defaultCustomAttributeValues;
|
||||
}
|
||||
|
||||
records.push(record);
|
||||
});
|
||||
|
||||
return records;
|
||||
},
|
||||
});
|
||||
|
||||
export { ProductConfiguratorWidget };
|
||||
@@ -0,0 +1,260 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { SaleOrderLineProductField } from '@sale/js/sale_product_field';
|
||||
import { OptionalProductsModal } from "@sale_product_configurator/js/product_configurator_modal";
|
||||
import {
|
||||
selectOrCreateProduct,
|
||||
getSelectedVariantValues,
|
||||
getNoVariantAttributeValues,
|
||||
} from "sale.VariantMixin";
|
||||
|
||||
|
||||
patch(SaleOrderLineProductField.prototype, 'sale_product_configurator', {
|
||||
|
||||
setup() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.rpc = useService("rpc");
|
||||
this.ui = useService("ui");
|
||||
},
|
||||
|
||||
async _onProductTemplateUpdate() {
|
||||
this._super(...arguments);
|
||||
const result = await this.orm.call(
|
||||
'product.template',
|
||||
'get_single_product_variant',
|
||||
[this.props.record.data.product_template_id[0]],
|
||||
);
|
||||
if(result && result.product_id) {
|
||||
if (this.props.record.data.product_id != result.product_id.id) {
|
||||
await this.props.record.update({
|
||||
product_id: [result.product_id, 'whatever'],
|
||||
});
|
||||
if (result.has_optional_products) {
|
||||
this._openProductConfigurator('options');
|
||||
} else {
|
||||
this._onProductUpdate();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!result.mode || result.mode === 'configurator') {
|
||||
this._openProductConfigurator('add');
|
||||
} else {
|
||||
// only triggered when sale_product_matrix is installed.
|
||||
this._openGridConfigurator(result.mode);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_editProductConfiguration() {
|
||||
this._super(...arguments);
|
||||
if (this.props.record.data.is_configurable_product) {
|
||||
this._openProductConfigurator('edit');
|
||||
}
|
||||
},
|
||||
|
||||
get isConfigurableTemplate() {
|
||||
return this._super(...arguments) || this.props.record.data.is_configurable_product;
|
||||
},
|
||||
|
||||
async _openProductConfigurator(mode) {
|
||||
const saleOrderRecord = this.props.record.model.root;
|
||||
const pricelistId = saleOrderRecord.data.pricelist_id ? saleOrderRecord.data.pricelist_id[0] : false;
|
||||
const productTemplateId = this.props.record.data.product_template_id[0];
|
||||
const $modal = $(
|
||||
await this.rpc(
|
||||
"/sale_product_configurator/configure",
|
||||
{
|
||||
product_template_id: productTemplateId,
|
||||
quantity: this.props.record.data.product_uom_qty || 1,
|
||||
pricelist_id: pricelistId,
|
||||
product_template_attribute_value_ids: this.props.record.data.product_template_attribute_value_ids.records.map(
|
||||
record => record.data.id
|
||||
),
|
||||
product_no_variant_attribute_value_ids: this.props.record.data.product_no_variant_attribute_value_ids.records.map(
|
||||
record => record.data.id
|
||||
),
|
||||
context: this.props.record.context,
|
||||
},
|
||||
)
|
||||
);
|
||||
const productSelector = `input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked`;
|
||||
// TODO VFE drop this selectOrCreate and make it so that
|
||||
// get_single_product_variant returns first variant as well.
|
||||
// and use specified product on edition mode.
|
||||
const productId = await selectOrCreateProduct.call(
|
||||
this,
|
||||
$modal,
|
||||
parseInt($modal.find(productSelector).first().val(), 10),
|
||||
productTemplateId,
|
||||
false
|
||||
);
|
||||
$modal.find(productSelector).val(productId);
|
||||
const variantValues = getSelectedVariantValues($modal);
|
||||
const noVariantAttributeValues = getNoVariantAttributeValues($modal);
|
||||
const customAttributeValues = this.props.record.data.product_custom_attribute_value_ids.records.map(
|
||||
record => {
|
||||
// NOTE: this dumb formatting is necessary to avoid
|
||||
// modifying the shared code between frontend & backend for now.
|
||||
return {
|
||||
custom_value: record.data.custom_value,
|
||||
custom_product_template_attribute_value_id: {
|
||||
res_id: record.data.custom_product_template_attribute_value_id[0],
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
this.rootProduct = {
|
||||
product_id: productId,
|
||||
product_template_id: productTemplateId,
|
||||
quantity: parseFloat($modal.find('input[name="add_qty"]').val() || 1),
|
||||
variant_values: variantValues,
|
||||
product_custom_attribute_values: customAttributeValues,
|
||||
no_variant_attribute_values: noVariantAttributeValues,
|
||||
};
|
||||
const optionalProductsModal = new OptionalProductsModal(null, {
|
||||
rootProduct: this.rootProduct,
|
||||
pricelistId: pricelistId,
|
||||
okButtonText: this.env._t("Confirm"),
|
||||
cancelButtonText: this.env._t("Back"),
|
||||
title: this.env._t("Configure"),
|
||||
context: this.props.record.context,
|
||||
mode: mode,
|
||||
});
|
||||
let modalEl;
|
||||
optionalProductsModal.opened(() => {
|
||||
modalEl = optionalProductsModal.el;
|
||||
this.ui.activateElement(modalEl);
|
||||
});
|
||||
optionalProductsModal.on("closed", null, async () => {
|
||||
// Wait for the event that caused the close to bubble
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
this.ui.deactivateElement(modalEl);
|
||||
});
|
||||
optionalProductsModal.open();
|
||||
|
||||
let confirmed = false;
|
||||
optionalProductsModal.on("confirm", null, async () => {
|
||||
confirmed = true;
|
||||
const [
|
||||
mainProduct,
|
||||
...optionalProducts
|
||||
] = await optionalProductsModal.getAndCreateSelectedProducts();
|
||||
|
||||
await this.props.record.update(this._convertConfiguratorDataToUpdateData(mainProduct));
|
||||
this._onProductUpdate();
|
||||
const optionalProductLinesCreationContext = this._convertConfiguratorDataToLinesCreationContext(optionalProducts);
|
||||
for (let optionalProductLineCreationContext of optionalProductLinesCreationContext) {
|
||||
const line = await saleOrderRecord.data.order_line.addNew({
|
||||
position: 'bottom',
|
||||
context: optionalProductLineCreationContext,
|
||||
mode: 'readonly', // whatever but not edit !
|
||||
});
|
||||
// FIXME: update sets the field dirty otherwise on the next edit and click out it gets deleted
|
||||
line.update({ sequence: line.data.sequence });
|
||||
}
|
||||
saleOrderRecord.data.order_line.unselectRecord();
|
||||
});
|
||||
optionalProductsModal.on("closed", null, () => {
|
||||
if (confirmed) {
|
||||
return;
|
||||
}
|
||||
if (mode != 'edit') {
|
||||
this.props.record.update({
|
||||
product_template_id: false,
|
||||
product_id: false,
|
||||
product_uom_qty: 1.0,
|
||||
// TODO reset custom/novariant values (and remove onchange logic?)
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_convertConfiguratorDataToUpdateData(mainProduct) {
|
||||
let result = {
|
||||
// TODO find a way to get the real product name
|
||||
// bc 'whatever' is really displayed when showing the 'Product Variant' column
|
||||
product_id: [mainProduct.product_id, 'whatever'],
|
||||
product_uom_qty: mainProduct.quantity,
|
||||
};
|
||||
var customAttributeValues = mainProduct.product_custom_attribute_values;
|
||||
var customValuesCommands = [{ operation: "DELETE_ALL" }];
|
||||
if (customAttributeValues && customAttributeValues.length !== 0) {
|
||||
_.each(customAttributeValues, function (customValue) {
|
||||
customValuesCommands.push({
|
||||
operation: "CREATE",
|
||||
context: [
|
||||
{
|
||||
default_custom_product_template_attribute_value_id:
|
||||
customValue.custom_product_template_attribute_value_id,
|
||||
default_custom_value: customValue.custom_value,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
result.product_custom_attribute_value_ids = {
|
||||
operation: "MULTI",
|
||||
commands: customValuesCommands,
|
||||
};
|
||||
|
||||
var noVariantAttributeValues = mainProduct.no_variant_attribute_values;
|
||||
var noVariantCommands = [{ operation: "DELETE_ALL" }];
|
||||
if (noVariantAttributeValues && noVariantAttributeValues.length !== 0) {
|
||||
var resIds = _.map(noVariantAttributeValues, function (noVariantValue) {
|
||||
return { id: parseInt(noVariantValue.value) };
|
||||
});
|
||||
|
||||
noVariantCommands.push({
|
||||
operation: "ADD_M2M",
|
||||
ids: resIds,
|
||||
});
|
||||
}
|
||||
|
||||
result.product_no_variant_attribute_value_ids = {
|
||||
operation: "MULTI",
|
||||
commands: noVariantCommands,
|
||||
};
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Will map the optional producs data to sale.order.line
|
||||
* creation contexts.
|
||||
*
|
||||
* @param {Array} optionalProductsData The optional products data given by the configurator
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_convertConfiguratorDataToLinesCreationContext: function (optionalProductsData) {
|
||||
return optionalProductsData.map(productData => {
|
||||
return {
|
||||
default_product_id: productData.product_id,
|
||||
default_product_template_id: productData.product_template_id,
|
||||
default_product_uom_qty: productData.quantity,
|
||||
default_product_no_variant_attribute_value_ids: productData.no_variant_attribute_values.map(
|
||||
noVariantAttributeData => {
|
||||
return [4, parseInt(noVariantAttributeData.value)];
|
||||
}
|
||||
),
|
||||
default_product_custom_attribute_value_ids: productData.product_custom_attribute_values.map(
|
||||
customAttributeData => {
|
||||
return [
|
||||
0,
|
||||
0,
|
||||
{
|
||||
custom_product_template_attribute_value_id:
|
||||
customAttributeData.custom_product_template_attribute_value_id,
|
||||
custom_value: customAttributeData.custom_value,
|
||||
},
|
||||
];
|
||||
}
|
||||
)
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
/** @odoo-module */
|
||||
import FormView from 'web.FormView';
|
||||
import testUtils from 'web.test_utils';
|
||||
import { patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { ProductConfiguratorWidget } from "@sale_product_configurator/js/product_configurator_widget";
|
||||
const createView = testUtils.createView;
|
||||
|
||||
const getArch = function (){
|
||||
return '<form>' +
|
||||
'<sheet>' +
|
||||
'<field name="pricelist_id" widget="selection" />' +
|
||||
'<field name="sale_order_line" widget="section_and_note_one2many">' +
|
||||
'<tree editable="top"><control>' +
|
||||
'<create string="Add a product"/>' +
|
||||
'<create string="Add a section" context="{\'default_display_type\': \'line_section\'}"/>' +
|
||||
'<create string="Add a note" context="{\'default_display_type\': \'line_note\'}"/>' +
|
||||
'</control>' +
|
||||
'<field name="product_id" invisible="1"/>' +
|
||||
'<field name="product_template_id" widget="product_configurator"/>' +
|
||||
'<field name="product_uom_qty"/>' +
|
||||
'<field name="product_custom_attribute_value_ids" invisible="1"/>' +
|
||||
'</tree>' +
|
||||
'</field>' +
|
||||
'</sheet>' +
|
||||
'</form>';
|
||||
};
|
||||
|
||||
QUnit.module('Product Configurator', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
product_template: {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
},
|
||||
records: [{
|
||||
id: 42,
|
||||
display_name: "Customizable Desk"
|
||||
}]
|
||||
},
|
||||
product: {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
display_name: "Customizable Desk (1)"
|
||||
}, {
|
||||
id: 2,
|
||||
display_name: "Customizable Desk (2)"
|
||||
}]
|
||||
},
|
||||
sale_order: {
|
||||
fields: {
|
||||
id: {type: 'integer'},
|
||||
pricelist_id: {
|
||||
string: 'Pricelist',
|
||||
type: 'one2many',
|
||||
relation: 'pricelist'
|
||||
},
|
||||
sale_order_line: {
|
||||
string: 'lines',
|
||||
type: 'one2many',
|
||||
relation: 'sale_order_line'
|
||||
},
|
||||
}
|
||||
},
|
||||
sale_order_line: {
|
||||
fields: {
|
||||
product_template_id: {
|
||||
string: 'product template',
|
||||
type: 'many2one',
|
||||
relation: 'product_template'
|
||||
},
|
||||
product_id: {
|
||||
string: 'product',
|
||||
type: 'many2one',
|
||||
relation: 'product'
|
||||
},
|
||||
product_custom_attribute_value_ids: {
|
||||
string: 'product_custom_attribute_values',
|
||||
type: 'one2many',
|
||||
relation: 'product_custom_attribute_value'
|
||||
},
|
||||
product_uom_qty: {type: 'integer'},
|
||||
sequence: {type: 'integer'},
|
||||
}
|
||||
},
|
||||
product_custom_attribute_value: {
|
||||
fields: {
|
||||
id: {type: 'integer'},
|
||||
sale_order_line_id: {
|
||||
string: 'sale order line',
|
||||
type: 'many2one',
|
||||
relation: 'sale_order_line'
|
||||
}
|
||||
}
|
||||
},
|
||||
sale_product_configurator: {
|
||||
fields: {
|
||||
product_template_id: {
|
||||
string: 'product',
|
||||
type: 'many2one',
|
||||
relation: 'product_template'
|
||||
},
|
||||
product_template_attribute_value_ids: {
|
||||
type: 'many2many',
|
||||
relation: 'product_template_attribute_value'
|
||||
},
|
||||
product_no_variant_attribute_value_ids: {
|
||||
type: 'many2many',
|
||||
relation: 'product_template_attribute_value'
|
||||
},
|
||||
product_custom_attribute_value_ids: {
|
||||
type: 'many2many',
|
||||
relation: 'product_attribute_custom_value'
|
||||
}
|
||||
},
|
||||
records: [{
|
||||
product_template_id: 42
|
||||
}]
|
||||
},
|
||||
product_template_attribute_value: {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
}
|
||||
},
|
||||
product_attribute_custom_value: {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
}
|
||||
},
|
||||
pricelist: {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}, function () {
|
||||
QUnit.test('Select a non configurable product template and verify that the product_id is correctly set', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: getArch(),
|
||||
mockRPC: function (route, params) {
|
||||
if (params.method === 'get_single_product_variant') {
|
||||
assert.ok(true);
|
||||
return Promise.resolve({product_id: 2});
|
||||
}
|
||||
// FIXME awa: this shouldn't be here since the read is done in 'event_sale'
|
||||
// But at the moment there is no easy way to solve such cross module 'include' issues
|
||||
if (params.method === 'read') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
intercepts: {
|
||||
do_action: function (ev) {
|
||||
if (ev.data.action === 'sale_product_configurator.sale_product_configurator_action') {
|
||||
assert.ok(false, "Should not execute the configure action");
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
await testUtils.dom.click(form.$("a:contains('Add a product')"));
|
||||
await testUtils.fields.many2one.searchAndClickItem("product_template_id", {item: 'Customizable Desk'});
|
||||
// check that product_id is correctly set to 2
|
||||
assert.strictEqual(form.renderer.state.data.sale_order_line.data[0].data.product_id.data.id, 2);
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Select a configurable product template and verify that the product configurator is opened', async function (assert) {
|
||||
patchWithCleanup(ProductConfiguratorWidget.prototype, {
|
||||
_openProductConfigurator() {
|
||||
assert.step("open configurator");
|
||||
}
|
||||
});
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: getArch(),
|
||||
mockRPC: function (route, params) {
|
||||
if (params.method === 'get_single_product_variant') {
|
||||
assert.ok(true);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
await testUtils.dom.click(form.$("a:contains('Add a product')"));
|
||||
assert.verifySteps([]);
|
||||
await testUtils.fields.many2one.searchAndClickItem("product_template_id", {item: 'Customizable Desk'});
|
||||
assert.verifySteps(["open configurator"]);
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('trigger_up the "add_record" event and checks that rows are correctly added to the list', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: getArch()
|
||||
});
|
||||
|
||||
let list = form.renderer.allFieldWidgets[form.handle][1];
|
||||
|
||||
list.trigger_up('add_record', {
|
||||
context: [{default_product_id: 1, default_product_uom_qty: 2}, {default_product_id: 2, default_product_uom_qty: 3}],
|
||||
forceEditable: "bottom" ,
|
||||
allowWarning: true
|
||||
});
|
||||
await testUtils.nextTick();
|
||||
|
||||
assert.containsN(list, "tr.o_data_row", 2);
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('drag and drop rows containing product_configurator many2one', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
this.data.sale_order.records = [
|
||||
{ id: 1, sale_order_line: [1, 2] }
|
||||
];
|
||||
this.data.sale_order_line.records = [
|
||||
{ id: 1, sequence: 5, product_id: 1 },
|
||||
{ id: 2, sequence: 15, product_id: 2 },
|
||||
];
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="sale_order_line"/>
|
||||
</form>`,
|
||||
archs: {
|
||||
'sale_order_line,false,list': `
|
||||
<tree editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="product_id" widget="product_configurator"/>
|
||||
</tree>`,
|
||||
},
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsN(form, '.o_data_row', 2);
|
||||
assert.strictEqual(form.$('.o_data_row').text(), 'Customizable Desk (1)Customizable Desk (2)');
|
||||
assert.containsN(form, '.o_data_row .o_row_handle', 2);
|
||||
|
||||
// move first row below second
|
||||
const $firstHandle = form.$('.o_data_row:nth(0) .o_row_handle');
|
||||
const $secondHandle = form.$('.o_data_row:nth(1) .o_row_handle');
|
||||
await testUtils.dom.dragAndDrop($firstHandle, $secondHandle, { position: 'bottom' });
|
||||
|
||||
assert.strictEqual(form.$('.o_data_row').text(), 'Customizable Desk (2)Customizable Desk (1)');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user