[IMP] sale_product_configurator: improve p. configurator
Task #1891970
Original p. configurator commit d3530eb
Purpose
=======
- The p. configurator now comes with a widget that is "o2m" like in the SO lines view.
The widget is only used on the added "product_template_id" field on the SO line.
This widget controls the opening of the configuration window and removes the need of a "Configure a product" button.
The "SectionAndNoteListRenderer" is now cleaned from p. configurator specific code.
The widget is also responsible for handling the configuration provided by the p. configurator form
and applying it on the SO line with a 'field_changed' event that updates all the necessary fields.
- Added support for 'MULTI' and 'DELETE_ALL' operations on X2Many fields in basic_model.js
- 'MULTI' allows to batch multiple operations at once
- 'DELETE_ALL' behaves like 'DELETE' with all the current data of the field
Spec
=======
- remove "configure a product" from sale order lines
When the product configurator is active, replace the product_product_id by a product_template_id
in the sale order line. When we select a template without variants, it sets the variant automatically,
but when we select a product template having variants, it opens the configurator dialog.
- add a widget to modify the product configuration in the sale order line (next to product template field)
- UX improvements:
- Invert image and configuration in the main screen
- If the product doesn't have an image, hide it instead of showing the placeholder (only for first screen?)
- new independent option in sales settings to activate product configurator.
(same in e-commerce)
- demo data: Change demo data to set Customizable Desk & Conference Chair as make to order
- If only one attribute value that is Custom, don't display radio, selection or even color box
This commit is contained in:
@@ -6,9 +6,6 @@ odoo.define('account.section_and_note_backend', function (require) {
|
||||
// [UPDATED] now also allows configuring products on sale order.
|
||||
|
||||
"use strict";
|
||||
var pyUtils = require('web.py_utils');
|
||||
var core = require('web.core');
|
||||
var _t = core._t;
|
||||
var FieldChar = require('web.basic_fields').FieldChar;
|
||||
var FieldOne2Many = require('web.relational_fields').FieldOne2Many;
|
||||
var fieldRegistry = require('web.field_registry');
|
||||
@@ -72,121 +69,6 @@ var SectionAndNoteListRenderer = ListRenderer.extend({
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
self.$('> table').addClass('o_section_and_note_list_view');
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Add support for product configurator
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onAddRecord: function (ev) {
|
||||
// we don't want the browser to navigate to a the # url
|
||||
ev.preventDefault();
|
||||
|
||||
// we don't want the click to cause other effects, such as unselecting
|
||||
// the row that we are creating, because it counts as a click on a tr
|
||||
ev.stopPropagation();
|
||||
|
||||
// but we do want to unselect current row
|
||||
var self = this;
|
||||
this.unselectRow().then(function () {
|
||||
var context = ev.currentTarget.dataset.context;
|
||||
|
||||
var pricelistId = self._getPricelistId();
|
||||
if (context && pyUtils.py_eval(context).open_product_configurator){
|
||||
self._rpc({
|
||||
model: 'ir.model.data',
|
||||
method: 'xmlid_to_res_id',
|
||||
kwargs: {xmlid: 'sale.sale_product_configurator_view_form'},
|
||||
}).then(function (res_id) {
|
||||
self.do_action({
|
||||
name: _t('Configure a product'),
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'sale.product.configurator',
|
||||
views: [[res_id, 'form']],
|
||||
target: 'new',
|
||||
context: {
|
||||
'default_pricelist_id': pricelistId
|
||||
}
|
||||
}, {
|
||||
on_close: function (products) {
|
||||
if (products && products !== 'special'){
|
||||
self.trigger_up('add_record', {
|
||||
context: self._productsToRecords(products),
|
||||
forceEditable: "bottom" ,
|
||||
allowWarning: true,
|
||||
onSuccess: function (){
|
||||
self.unselectRow();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
self.trigger_up('add_record', {context: context && [context]}); // TODO write a test, the deferred was not considered
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Will try to get the pricelist_id value from the parent sale_order form
|
||||
*
|
||||
* @private
|
||||
* @returns {integer} pricelist_id's id
|
||||
*/
|
||||
_getPricelistId: function () {
|
||||
var saleOrderForm = this.getParent() && this.getParent().getParent();
|
||||
var stateData = saleOrderForm && saleOrderForm.state && saleOrderForm.state.data;
|
||||
var pricelist_id = stateData.pricelist_id && stateData.pricelist_id.data && stateData.pricelist_id.data.id;
|
||||
|
||||
return pricelist_id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Will map the products to appropriate record objects that are
|
||||
* ready for the default_get
|
||||
*
|
||||
* @private
|
||||
* @param {Array} products The products to transform into records
|
||||
*/
|
||||
_productsToRecords: function (products) {
|
||||
var records = [];
|
||||
_.each(products, function (product){
|
||||
var record = {
|
||||
default_product_id: product.product_id,
|
||||
default_product_uom_qty: product.quantity
|
||||
};
|
||||
|
||||
if (product.no_variant_attribute_values) {
|
||||
var default_product_no_variant_attribute_values = [];
|
||||
_.each(product.no_variant_attribute_values, function (attribute_value) {
|
||||
default_product_no_variant_attribute_values.push(
|
||||
[4, parseInt(attribute_value.value)]
|
||||
);
|
||||
});
|
||||
record['default_product_no_variant_attribute_value_ids']
|
||||
= default_product_no_variant_attribute_values;
|
||||
}
|
||||
|
||||
if (product.product_custom_attribute_values) {
|
||||
var default_custom_attribute_values = [];
|
||||
_.each(product.product_custom_attribute_values, function (attribute_value) {
|
||||
default_custom_attribute_values.push(
|
||||
[0, 0, {
|
||||
attribute_value_id: attribute_value.attribute_value_id,
|
||||
custom_value: attribute_value.custom_value
|
||||
}]
|
||||
);
|
||||
});
|
||||
record['default_product_custom_attribute_value_ids']
|
||||
= default_custom_attribute_values;
|
||||
}
|
||||
|
||||
records.push(record);
|
||||
});
|
||||
|
||||
return records;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ tour.register('event_configurator_tour', {
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
$input.trigger(keyDownEvent);
|
||||
}
|
||||
},
|
||||
id: 'product_selection_step'
|
||||
}, {
|
||||
trigger: 'ul.ui-autocomplete a:contains("EVENT")',
|
||||
run: 'click'
|
||||
|
||||
@@ -2,8 +2,7 @@ odoo.define('event.configurator', function (require) {
|
||||
|
||||
var relationalFields = require('web.relational_fields');
|
||||
var FieldsRegistry = require('web.field_registry');
|
||||
var core = require('web.core');
|
||||
var _t = core._t;
|
||||
var EventConfiguratorWidgetMixin = require('event_sale.EventConfiguratorWidgetMixin');
|
||||
|
||||
/**
|
||||
* The event configurator widget is a simple FieldMany2One that adds the capability
|
||||
@@ -15,38 +14,21 @@ var _t = core._t;
|
||||
*
|
||||
* !!! It should only be used on a product_id field !!!
|
||||
*/
|
||||
var EventConfiguratorWidget = relationalFields.FieldMany2One.extend({
|
||||
events: _.extend({}, relationalFields.FieldMany2One.prototype.events, {
|
||||
'click .o_event_sale_js_event_configurator_edit': '_onEditEventConfiguration'
|
||||
}),
|
||||
var EventConfiguratorWidget = relationalFields.FieldMany2One.extend(EventConfiguratorWidgetMixin, {
|
||||
events: _.extend({},
|
||||
relationalFields.FieldMany2One.prototype.events,
|
||||
EventConfiguratorWidgetMixin.events
|
||||
),
|
||||
|
||||
/**
|
||||
* This method will check if the current product set on the SO line is a configurable event.
|
||||
* -> If so, we add a 'Edit Configuration' button next to the dropdown.
|
||||
*
|
||||
* @see _addEventConfigurationEditButton for more info
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var prom = this._super.apply(this, arguments);
|
||||
|
||||
var $inputDropdown = this.$('.o_input_dropdown');
|
||||
|
||||
if (this.recordData.event_ok &&
|
||||
$inputDropdown.length !== 0 &&
|
||||
this.$('.o_event_sale_js_event_configurator_edit').length === 0) {
|
||||
var $editConfigurationButton = $('<button>', {
|
||||
type: 'button',
|
||||
class: 'fa fa-pencil btn btn-secondary o_event_sale_js_event_configurator_edit',
|
||||
tabindex: '-1',
|
||||
draggable: false,
|
||||
'aria-label': _t('Edit Configuration'),
|
||||
title: _t('Edit Configuration')
|
||||
});
|
||||
|
||||
$inputDropdown.after($editConfigurationButton);
|
||||
}
|
||||
|
||||
return prom;
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
self._addEventConfigurationEditButton();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -65,79 +47,8 @@ var EventConfiguratorWidget = relationalFields.FieldMany2One.extend({
|
||||
return;
|
||||
}
|
||||
|
||||
var product_id = event.data.changes.product_id.id;
|
||||
if (product_id){
|
||||
this._rpc({
|
||||
model: 'product.product',
|
||||
method: 'read',
|
||||
args: [product_id, ['event_ok']],
|
||||
}).then(function (result) {
|
||||
if (result && result[0].event_ok){
|
||||
self._openEventConfigurator({
|
||||
default_product_id: product_id
|
||||
},
|
||||
event.data.dataPointID
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the event configurator in 'edit' mode.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEditEventConfiguration: function () {
|
||||
var defaultValues = {
|
||||
default_product_id: this.recordData.product_id.data.id
|
||||
};
|
||||
|
||||
if (this.recordData.event_id) {
|
||||
defaultValues['default_event_id'] = this.recordData.event_id.data.id;
|
||||
}
|
||||
|
||||
if (this.recordData.event_ticket_id) {
|
||||
defaultValues['default_event_ticket_id'] = this.recordData.event_ticket_id.data.id;
|
||||
}
|
||||
|
||||
this._openEventConfigurator(defaultValues, this.dataPointID);
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the event configurator to allow configuring the SO line with events information.
|
||||
*
|
||||
* When the window is closed, configured values are used to trigger a 'field_changed'
|
||||
* event to modify the current SO line.
|
||||
*
|
||||
* If the window is closed without providing the required values 'event_id' and
|
||||
* 'event_ticket_id', the product_id field is cleaned.
|
||||
*
|
||||
* @param {Object} data various "default_" values
|
||||
* @param {string} dataPointId
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_openEventConfigurator: function (data, dataPointId) {
|
||||
var self = this;
|
||||
this.do_action('event_sale.event_configurator_action', {
|
||||
additional_context: data,
|
||||
on_close: function (result) {
|
||||
if (result && result !== 'special'){
|
||||
self.trigger_up('field_changed', {
|
||||
dataPointID: dataPointId,
|
||||
changes: result.eventConfiguration
|
||||
});
|
||||
} else {
|
||||
if (!self.recordData.event_id || !self.recordData.event_ticket_id) {
|
||||
self.trigger_up('field_changed', {
|
||||
dataPointID: dataPointId,
|
||||
changes: {product_id: {operation: 'DELETE_ALL'}}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
var productId = event.data.changes.product_id.id;
|
||||
self._checkForEvent(productId, event.data.dataPointID);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
odoo.define('event_sale.EventConfiguratorWidgetMixin', function (require) {
|
||||
'use strict';
|
||||
|
||||
var core = require('web.core');
|
||||
var _t = core._t;
|
||||
|
||||
/**
|
||||
* This mixin intends to gather methods that will be used by the event configurator.
|
||||
* They're extracted here to be used by other widgets if necessary (namely the product_configurator_widget)
|
||||
*/
|
||||
var EventConfiguratorWidgetMixin = {
|
||||
events: {
|
||||
'click .o_event_sale_js_event_configurator_edit': '_onEditEventConfiguration'
|
||||
},
|
||||
/**
|
||||
* This method will check if the current product set on the SO line is a configurable event.
|
||||
* -> If so, we add a 'Edit Configuration' button next to the dropdown.
|
||||
*
|
||||
*/
|
||||
_addEventConfigurationEditButton: function () {
|
||||
var $inputDropdown = this.$('.o_input_dropdown');
|
||||
|
||||
if (this.recordData.event_ok &&
|
||||
$inputDropdown.length !== 0 &&
|
||||
this.$('.o_event_sale_js_event_configurator_edit').length === 0) {
|
||||
var $editConfigurationButton = $('<button>', {
|
||||
type: 'button',
|
||||
class: 'fa fa-pencil btn btn-secondary o_event_sale_js_event_configurator_edit',
|
||||
tabindex: '-1',
|
||||
draggable: false,
|
||||
'aria-label': _t('Edit Configuration'),
|
||||
title: _t('Edit Configuration')
|
||||
});
|
||||
|
||||
$inputDropdown.after($editConfigurationButton);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* This method will check if the productId needs configuration or not:
|
||||
*
|
||||
* @param {integer} productId
|
||||
* @param {string} dataPointID
|
||||
*/
|
||||
_checkForEvent: function (productId, dataPointID) {
|
||||
var self = this;
|
||||
if (productId){
|
||||
this._rpc({
|
||||
model: 'product.product',
|
||||
method: 'read',
|
||||
args: [productId, ['event_ok']],
|
||||
}).then(function (result) {
|
||||
if (result && result[0].event_ok){
|
||||
self._openEventConfigurator({
|
||||
default_product_id: productId
|
||||
},
|
||||
dataPointID
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the event configurator in 'edit' mode.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEditEventConfiguration: function () {
|
||||
var defaultValues = {
|
||||
default_product_id: this.recordData.product_id.data.id
|
||||
};
|
||||
|
||||
if (this.recordData.event_id) {
|
||||
defaultValues['default_event_id'] = this.recordData.event_id.data.id;
|
||||
}
|
||||
|
||||
if (this.recordData.event_ticket_id) {
|
||||
defaultValues['default_event_ticket_id'] = this.recordData.event_ticket_id.data.id;
|
||||
}
|
||||
|
||||
this._openEventConfigurator(defaultValues, this.dataPointID);
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the event configurator to allow configuring the SO line with events information.
|
||||
*
|
||||
* When the window is closed, configured values are used to trigger a 'field_changed'
|
||||
* event to modify the current SO line.
|
||||
*
|
||||
* If the window is closed without providing the required values 'event_id' and
|
||||
* 'event_ticket_id', the product_id field is cleaned.
|
||||
*
|
||||
* @param {Object} data various "default_" values
|
||||
* @param {string} dataPointId
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_openEventConfigurator: function (data, dataPointId) {
|
||||
var self = this;
|
||||
this.do_action('event_sale.event_configurator_action', {
|
||||
additional_context: data,
|
||||
on_close: function (result) {
|
||||
if (result && result !== 'special'){
|
||||
self.trigger_up('field_changed', {
|
||||
dataPointID: dataPointId,
|
||||
changes: result.eventConfiguration
|
||||
});
|
||||
} else {
|
||||
if (!self.recordData.event_id || !self.recordData.event_ticket_id) {
|
||||
self.trigger_up('field_changed', {
|
||||
dataPointID: dataPointId,
|
||||
changes: {product_id: {operation: 'DELETE_ALL'}}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return EventConfiguratorWidgetMixin;
|
||||
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
<xpath expr="script[last()]" position="after">
|
||||
<script type="text/javascript" src="/event_sale/static/src/js/event_configurator_controller.js"></script>
|
||||
<script type="text/javascript" src="/event_sale/static/src/js/event_configurator_view.js"></script>
|
||||
<script type="text/javascript" src="/event_sale/static/src/js/event_configurator_widget_mixin.js"></script>
|
||||
<script type="text/javascript" src="/event_sale/static/src/js/event_configurator_widget.js"></script>
|
||||
<script type="text/javascript" src="/event_sale/static/src/js/event_configurator_test_ui.js"></script>
|
||||
</xpath>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
{
|
||||
'name': "Event Sale Product Configurator",
|
||||
'version': '1.0',
|
||||
'category': 'Hidden',
|
||||
'summary': "Bridge module between event_sale and sale_product_configurator",
|
||||
|
||||
'description': """
|
||||
Technical bridge module installed to make the event_configurator work on the product_template_id field
|
||||
added by the product configurator.
|
||||
""",
|
||||
|
||||
'depends': ['sale_product_configurator', 'event_sale'],
|
||||
'data': [
|
||||
'views/assets.xml',
|
||||
],
|
||||
'auto_install': True
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
odoo.define("event_sale_product_configurator.event_configurator_tour", function (require) {
|
||||
"use strict";
|
||||
/**
|
||||
* We need to replace the trigger of the product selection step because the
|
||||
* "sale_product_configurator" module replaces the "product_id" field with
|
||||
* a "product_template_id" field.
|
||||
*/
|
||||
var tour = require('web_tour.tour');
|
||||
require('event.event_configurator_tour');
|
||||
|
||||
var productSelectionStepIndex = _.findIndex(tour.tours.event_configurator_tour.steps, function (step) {
|
||||
return (step.id === 'product_selection_step');
|
||||
});
|
||||
|
||||
var productSelectionStep = tour.tours.event_configurator_tour.steps[productSelectionStepIndex];
|
||||
productSelectionStep.trigger = 'div[name="product_template_id"] input';
|
||||
productSelectionStep.run = function (){
|
||||
var $input = $('div[name="product_template_id"] input');
|
||||
$input.click();
|
||||
$input.val('EVENT');
|
||||
// fake keydown to trigger search
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
$input.trigger(keyDownEvent);
|
||||
};
|
||||
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
odoo.define('event_sale.event_sale_product_configurator', function (require) {
|
||||
|
||||
var relationalFields = require('web.relational_fields');
|
||||
var ProductConfiguratorWidget = require('sale_product_configurator.product_configurator');
|
||||
var EventConfiguratorWidgetMixin = require('event_sale.EventConfiguratorWidgetMixin');
|
||||
|
||||
/**
|
||||
* The ProductConfiguratorWidget is overridden to add the features of the EventConfiguratorWidget.
|
||||
*/
|
||||
var productConfiguratorEvents = ProductConfiguratorWidget.prototype.events;
|
||||
ProductConfiguratorWidget.include(EventConfiguratorWidgetMixin);
|
||||
|
||||
ProductConfiguratorWidget.include({
|
||||
events: _.extend({},
|
||||
relationalFields.FieldMany2One.prototype.events,
|
||||
productConfiguratorEvents,
|
||||
EventConfiguratorWidgetMixin.events
|
||||
),
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
self._addEventConfigurationEditButton();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {integer} productId
|
||||
* @param {string} dataPointID
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onSimpleProductFound: function (productId, dataPointID) {
|
||||
this._checkForEvent(productId, dataPointID);
|
||||
}
|
||||
});
|
||||
|
||||
return ProductConfiguratorWidget;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="assets_backend" inherit_id="web.assets_backend" name="event_sale_product_configurator assets backend">
|
||||
<xpath expr="script[last()]" position="after">
|
||||
<script type="text/javascript" src="/event_sale_product_configurator/static/src/js/event_configurator_test_ui.js"></script>
|
||||
<script type="text/javascript" src="/event_sale_product_configurator/static/src/js/product_configurator_widget.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -13,7 +13,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
" * Checked : Product are visible for every company, even if a company is defined on the partner.\n"
|
||||
" * Unchecked : Each company can see only its product (product where company is defined). Product not related to a company are visible for all companies.")
|
||||
group_uom = fields.Boolean("Units of Measure", implied_group='uom.group_uom')
|
||||
group_product_variant = fields.Boolean("Variants and Options", implied_group='product.group_product_variant')
|
||||
group_product_variant = fields.Boolean("Variants", implied_group='product.group_product_variant')
|
||||
module_sale_product_configurator = fields.Boolean("Product Configurator")
|
||||
group_stock_packaging = fields.Boolean('Product Packagings',
|
||||
implied_group='product.group_stock_packaging')
|
||||
|
||||
@@ -88,6 +88,6 @@ class ProductAttributeCustomValue(models.Model):
|
||||
_rec_name = 'custom_value'
|
||||
_description = 'Product Attribute Custom Value'
|
||||
|
||||
attribute_value_id = fields.Many2one('product.attribute.value', string='Attribute')
|
||||
attribute_value_id = fields.Many2one('product.attribute.value', string='Attribute Value')
|
||||
sale_order_line_id = fields.Many2one('sale.order.line', string='Sale order line')
|
||||
custom_value = fields.Char('Custom value')
|
||||
|
||||
@@ -223,6 +223,7 @@ class ProductTemplate(models.Model):
|
||||
# get the name before the change of context to benefit from prefetch
|
||||
display_name = self.name
|
||||
|
||||
display_image = True
|
||||
quantity = self.env.context.get('quantity', add_qty)
|
||||
context = dict(self.env.context, quantity=quantity, pricelist=pricelist.id if pricelist else False)
|
||||
product_template = self.with_context(context)
|
||||
@@ -257,6 +258,7 @@ class ProductTemplate(models.Model):
|
||||
)
|
||||
list_price = product.price_compute('list_price')[product.id]
|
||||
price = product.price if pricelist else list_price
|
||||
display_image = bool(product.image)
|
||||
else:
|
||||
product_template = product_template.with_context(current_attributes_price_extra=[v.price_extra or 0.0 for v in combination])
|
||||
list_price = product_template.price_compute('list_price')[product_template.id]
|
||||
@@ -279,6 +281,7 @@ class ProductTemplate(models.Model):
|
||||
'product_id': product.id,
|
||||
'product_template_id': product_template.id,
|
||||
'display_name': display_name,
|
||||
'display_image': display_image,
|
||||
'price': price,
|
||||
'list_price': list_price,
|
||||
'has_discounted_price': has_discounted_price,
|
||||
|
||||
@@ -1457,9 +1457,7 @@ class SaleOrderLine(models.Model):
|
||||
|
||||
result = {'domain': domain}
|
||||
|
||||
name = self.get_sale_order_line_multiline_description_sale(product)
|
||||
|
||||
vals.update(name=name)
|
||||
vals.update(name=self.get_sale_order_line_multiline_description_sale(product))
|
||||
|
||||
self._compute_tax_id()
|
||||
|
||||
@@ -1614,10 +1612,13 @@ class SaleOrderLine(models.Model):
|
||||
|
||||
def get_sale_order_line_multiline_description_sale(self, product):
|
||||
""" Compute a default multiline description for this sales order line.
|
||||
This method exists so it can be overridden in other modules to change how the default name is computed.
|
||||
In general only the product is used to compute the name, and this method would not be necessary (we could directly override the method in product).
|
||||
BUT in event_sale we need to know specifically the sales order line as well as the product to generate the name:
|
||||
the product is not sufficient because we also need to know the event_id and the event_ticket_id (both which belong to the sale order line).
|
||||
|
||||
In most cases the product description is enough but sometimes we need to append information that only
|
||||
exists on the sale order line itself.
|
||||
e.g:
|
||||
- custom attributes and attributes that don't create variants, both introduced by the "product configurator"
|
||||
- in event_sale we need to know specifically the sales order line as well as the product to generate the name:
|
||||
the product is not sufficient because we also need to know the event_id and the event_ticket_id (both which belong to the sale order line).
|
||||
"""
|
||||
return product.get_product_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants()
|
||||
|
||||
|
||||
@@ -13,31 +13,11 @@ var ProductConfiguratorMixin = {
|
||||
'click button.js_add_cart_json': 'onClickAddCartJSON',
|
||||
'change [data-attribute_exclusions]': 'onChangeVariant'
|
||||
},
|
||||
isSelectedVariantAllowed: true,
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* When a product is added or when the quantity is changed,
|
||||
* we need to refresh the total price row
|
||||
* TODO awa: add a container context to avoid global selectors ?
|
||||
*/
|
||||
computePriceTotal: function () {
|
||||
if ($('.js_price_total').length){
|
||||
var price = 0;
|
||||
$('.js_product.in_cart').each(function (){
|
||||
var quantity = parseInt($('input[name="add_qty"]').first().val());
|
||||
price += parseFloat($(this).find('.js_raw_price').html()) * quantity;
|
||||
});
|
||||
|
||||
$('.js_price_total .oe_currency_value').html(
|
||||
this._priceToStr(parseFloat(price))
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* When a variant is changed, this will check:
|
||||
* - If the selected combination is available or not
|
||||
@@ -45,14 +25,27 @@ var ProductConfiguratorMixin = {
|
||||
* - The display name of the product ("Customizable desk (White, Steel)")
|
||||
* - The new total price
|
||||
* - The need of adding a "custom value" input
|
||||
* If the custom value is the only available value
|
||||
* (defined by its data 'is_single_and_custom'),
|
||||
* the custom value will have it's own input & label
|
||||
*
|
||||
* 'change' events triggered by the user entered custom values are ignored since they
|
||||
* are not relevant
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
* @param {$.Element} [params.$container] force the used container
|
||||
*/
|
||||
onChangeVariant: function (ev) {
|
||||
onChangeVariant: function (ev, params) {
|
||||
var self = this;
|
||||
|
||||
if ($(ev.target).hasClass('variant_custom_value')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $component;
|
||||
if ($(ev.currentTarget).closest('form').length > 0){
|
||||
if (params && params.$container) {
|
||||
$component = params.$container;
|
||||
} else if ($(ev.currentTarget).closest('form').length > 0){
|
||||
$component = $(ev.currentTarget).closest('form');
|
||||
} else if ($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){
|
||||
$component = $(ev.currentTarget).closest('.oe_optional_products_modal');
|
||||
@@ -99,7 +92,7 @@ var ProductConfiguratorMixin = {
|
||||
}
|
||||
|
||||
if ($variantContainer) {
|
||||
if ($customInput && $customInput.data('is_custom')) {
|
||||
if ($customInput && $customInput.data('is_custom') === 'True') {
|
||||
var attributeValueId = $customInput.data('value_id');
|
||||
var attributeValueName = $customInput.data('value_name');
|
||||
|
||||
@@ -120,7 +113,7 @@ var ProductConfiguratorMixin = {
|
||||
var isRadioInput = $target.is('input[type=radio]') &&
|
||||
$target.closest('label.css_attribute_color').length === 0;
|
||||
|
||||
if (isRadioInput) {
|
||||
if (isRadioInput && $customInput.data('is_single_and_custom') !== 'True') {
|
||||
$input.addClass('custom_value_radio');
|
||||
$target.closest('div').after($input);
|
||||
} else {
|
||||
@@ -128,6 +121,7 @@ var ProductConfiguratorMixin = {
|
||||
html: attributeValueName + ': ',
|
||||
class: 'variant_custom_value_label'
|
||||
});
|
||||
$input.addClass('custom_value_own_line');
|
||||
$variantContainer.append($label).append($input);
|
||||
}
|
||||
}
|
||||
@@ -442,7 +436,6 @@ var ProductConfiguratorMixin = {
|
||||
disable = true;
|
||||
}
|
||||
|
||||
this.isSelectedVariantAllowed = !disable;
|
||||
$parent.toggleClass('css_not_available', disable);
|
||||
$parent.find("#add_to_cart").toggleClass('disabled', disable);
|
||||
$parent
|
||||
@@ -545,10 +538,16 @@ var ProductConfiguratorMixin = {
|
||||
];
|
||||
|
||||
// update images only when changing product
|
||||
if (combination.product_id !== this.last_product_id) {
|
||||
// or when either ids are 'false', meaning dynamic products.
|
||||
// Dynamic products don't have images BUT they may have invalid
|
||||
// combinations that need to disable the image.
|
||||
if (!combination.product_id ||
|
||||
!this.last_product_id ||
|
||||
combination.product_id !== this.last_product_id) {
|
||||
this.last_product_id = combination.product_id;
|
||||
self._updateProductImage(
|
||||
$parent.closest(rootComponentSelectors.join(', ')),
|
||||
combination.display_image,
|
||||
combination.product_id,
|
||||
combination.product_template_id,
|
||||
combination.carousel
|
||||
@@ -569,9 +568,9 @@ var ProductConfiguratorMixin = {
|
||||
$parent
|
||||
.find('.js_raw_price')
|
||||
.first()
|
||||
.html(combination.price);
|
||||
.html(combination.price)
|
||||
.trigger('change');
|
||||
|
||||
this.computePriceTotal();
|
||||
this.handleCustomValues($(ev.target));
|
||||
},
|
||||
|
||||
@@ -599,10 +598,12 @@ var ProductConfiguratorMixin = {
|
||||
*
|
||||
* @private
|
||||
* @param {$.Element} $productContainer
|
||||
* @param {boolean} displayImage will hide the image if true. It will use the 'invisible' class
|
||||
* instead of d-none to prevent layout change
|
||||
* @param {integer} product_id
|
||||
* @param {integer} productTemplateId
|
||||
*/
|
||||
_updateProductImage: function ($productContainer, productId, productTemplateId) {
|
||||
_updateProductImage: function ($productContainer, displayImage, productId, productTemplateId) {
|
||||
var model = productId ? 'product.product' : 'product.template';
|
||||
var modelId = productId || productTemplateId;
|
||||
var imageUrl = '/web/image/{0}/{1}/' + (this._productImageField ? this._productImageField : 'image');
|
||||
@@ -618,7 +619,12 @@ var ProductConfiguratorMixin = {
|
||||
];
|
||||
|
||||
var $img = $productContainer.find(imagesSelectors.join(', '));
|
||||
$img.attr('src', imageSrc);
|
||||
|
||||
if (displayImage) {
|
||||
$img.removeClass('invisible').attr('src', imageSrc);
|
||||
} else {
|
||||
$img.addClass('invisible');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -636,20 +642,13 @@ var ProductConfiguratorMixin = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Website behavior is slightly different from backend so we append
|
||||
* "_website" to URLs to lead to a different route
|
||||
*
|
||||
* TODO this should be overriden in website_sale instead.
|
||||
* Extension point for website_sale
|
||||
*
|
||||
* @private
|
||||
* @param {string} uri The uri to adapt
|
||||
*/
|
||||
_getUri: function (uri) {
|
||||
if (this.isWebsite) {
|
||||
return uri + '_website';
|
||||
} else {
|
||||
return uri;
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -39,18 +39,23 @@ tour.register('sale_tour', {
|
||||
content: _t("Click here to add some products or services to your quotation."),
|
||||
position: "bottom",
|
||||
}, {
|
||||
trigger: ".o_form_editable .o_field_many2one[name='product_id'] input",
|
||||
trigger: ".o_field_widget[name=product_id] input, .o_field_widget[name=product_template_id] input",
|
||||
extra_trigger: ".o_sale_order",
|
||||
content: _t("Select a product, or create a new one on the fly."),
|
||||
position: "right",
|
||||
run: function (actions) {
|
||||
actions.text("DESK0001", this.$anchor.find("input"));
|
||||
actions.text("DESK0001", this.$anchor.find('input'));
|
||||
// fake keydown to trigger search
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
this.$anchor.trigger(keyDownEvent);
|
||||
var $descriptionElement = $('.o_form_editable textarea[name="name"]');
|
||||
// when description changes, we know the product has been created
|
||||
$descriptionElement.change(function () {
|
||||
$descriptionElement.addClass('product_creation_success');
|
||||
});
|
||||
}
|
||||
},
|
||||
id: 'product_selection_step'
|
||||
}, {
|
||||
trigger: '.o_m2o_dropdown_option a:contains("DESK0001")'
|
||||
}, {
|
||||
|
||||
@@ -75,7 +75,7 @@ label.css_attribute_color.css_not_available {
|
||||
|
||||
.attribute_name {
|
||||
padding-bottom: 0.5rem;
|
||||
display: inline-block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.radio_input {
|
||||
@@ -91,14 +91,25 @@ label.css_attribute_color.css_not_available {
|
||||
|
||||
.variant_custom_value {
|
||||
margin-bottom: 0.7rem;
|
||||
|
||||
&.custom_value_own_line {
|
||||
width: 60%;
|
||||
display: inline-block;
|
||||
margin-left: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.custom_value_radio {
|
||||
margin: 0.3rem 0rem 0.3rem 1.6rem;
|
||||
}
|
||||
|
||||
select {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.variant_custom_value_label {
|
||||
margin: 0.3rem 0rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="group_product_variant"/>
|
||||
<div class="text-muted">
|
||||
Set product attributes (e.g. color, size) to sell variants
|
||||
Sell variants of a product using attributes (size, color, etc.)
|
||||
</div>
|
||||
<div class="content-group" attrs="{'invisible': [('group_product_variant','=',False)]}">
|
||||
<div class="mt16">
|
||||
|
||||
@@ -379,7 +379,6 @@
|
||||
>
|
||||
<control>
|
||||
<create name="add_product_control" string="Add a product"/>
|
||||
<create name="configure_product_control" string="Configure a product" context="{'open_product_configurator': True}" groups="product.group_product_variant"/>
|
||||
<create name="add_section_control" string="Add a section" context="{'default_display_type': 'line_section'}"/>
|
||||
<create name="add_note_control" string="Add a note" context="{'default_display_type': 'line_note'}"/>
|
||||
</control>
|
||||
@@ -406,8 +405,6 @@
|
||||
'default_description_sale': name
|
||||
}"
|
||||
/>
|
||||
<field name="product_custom_attribute_value_ids" invisible="1" />
|
||||
<field name="product_no_variant_attribute_value_ids" invisible="1" />
|
||||
<field name="name" widget="section_and_note_text"/>
|
||||
<field
|
||||
name="product_uom_qty"
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
t-att-data-attribute_name="variant_id.attribute_id.name"
|
||||
t-attf-class="variant_attribute #{'d-none' if len(variant_id.product_template_value_ids) == 1 and not variant_id.product_template_value_ids[0].is_custom else ''}">
|
||||
|
||||
<!-- Used to customize layout if the only available attribute value is custom -->
|
||||
<t t-set="single_and_custom" t-value="len(variant_id.product_template_value_ids) == 1 and variant_id.product_template_value_ids[0].is_custom" />
|
||||
<strong t-field="variant_id.attribute_id.name" class="attribute_name"/>
|
||||
|
||||
<t t-if="variant_id.attribute_id.type == 'select'">
|
||||
<select
|
||||
t-att-data-attribute_id="variant_id.attribute_id.id"
|
||||
t-attf-class="form-control js_variant_change #{variant_id.attribute_id.create_variant}"
|
||||
t-attf-class="form-control js_variant_change #{variant_id.attribute_id.create_variant} #{'d-none' if single_and_custom else ''}"
|
||||
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)">
|
||||
<t t-foreach="variant_id.product_template_value_ids" t-as="value_id">
|
||||
<option t-att-value="value_id.id"
|
||||
@@ -22,7 +24,8 @@
|
||||
t-att-data-value_name="value_id.name"
|
||||
t-att-data-attribute_name="value_id.attribute_id.name"
|
||||
t-att-data-is_custom="value_id.is_custom"
|
||||
t-att-selected="first_possible_combination[variant_id_index] == value_id">
|
||||
t-att-selected="value_id in combination"
|
||||
t-att-data-is_single_and_custom="single_and_custom">
|
||||
<span t-field="value_id.name"/>
|
||||
<span t-if="value_id.price_extra" class="badge badge-pill badge-secondary">
|
||||
<!--
|
||||
@@ -48,20 +51,21 @@
|
||||
</t>
|
||||
|
||||
<t t-if="variant_id.attribute_id.type == 'radio'">
|
||||
<ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-unstyled">
|
||||
<ul t-att-data-attribute_id="variant_id.attribute_id.id" t-attf-class="list-unstyled #{'d-none' if single_and_custom else ''}">
|
||||
<t t-foreach="variant_id.product_template_value_ids" t-as="value_id">
|
||||
<li class="form-group js_attribute_value" style="margin: 0;">
|
||||
<label class="col-form-label">
|
||||
<div>
|
||||
<input type="radio"
|
||||
t-attf-class="js_variant_change radio_input #{variant_id.attribute_id.create_variant}"
|
||||
t-att-checked="first_possible_combination[variant_id_index] == value_id"
|
||||
t-att-checked="value_id in combination"
|
||||
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
|
||||
t-att-value="value_id.id"
|
||||
t-att-data-value_id="value_id.product_attribute_value_id.id"
|
||||
t-att-data-value_name="value_id.name"
|
||||
t-att-data-attribute_name="value_id.attribute_id.name"
|
||||
t-att-data-is_custom="value_id.is_custom" />
|
||||
t-att-data-is_custom="value_id.is_custom"
|
||||
t-att-data-is_single_and_custom="single_and_custom" />
|
||||
<div class="radio_input_value">
|
||||
<span t-field="value_id.name"/>
|
||||
<span class="badge badge-pill badge-secondary" t-if="value_id.price_extra">
|
||||
@@ -83,20 +87,21 @@
|
||||
</t>
|
||||
|
||||
<t t-if="variant_id.attribute_id.type == 'color'">
|
||||
<ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-inline">
|
||||
<ul t-att-data-attribute_id="variant_id.attribute_id.id" t-attf-class="list-inline #{'d-none' if single_and_custom else ''}">
|
||||
<li t-foreach="variant_id.product_template_value_ids" t-as="value_id" class="list-inline-item">
|
||||
<label t-attf-style="background-color:#{value_id.html_color or value_id.name if not value_id.is_custom else ''}"
|
||||
t-attf-class="css_attribute_color #{'active' if first_possible_combination[variant_id_index] == value_id else ''} #{'custom_value' if value_id.is_custom else ''}">
|
||||
t-attf-class="css_attribute_color #{'active' if value_id in combination else ''} #{'custom_value' if value_id.is_custom else ''}">
|
||||
<input type="radio"
|
||||
t-attf-class="js_variant_change #{variant_id.attribute_id.create_variant}"
|
||||
t-att-checked="first_possible_combination[variant_id_index] == value_id"
|
||||
t-att-checked="value_id in combination"
|
||||
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
|
||||
t-att-value="value_id.id"
|
||||
t-att-title="value_id.name"
|
||||
t-att-data-value_id="value_id.product_attribute_value_id.id"
|
||||
t-att-data-value_name="value_id.name"
|
||||
t-att-data-attribute_name="value_id.attribute_id.name"
|
||||
t-att-data-is_custom="value_id.is_custom"/>
|
||||
t-att-data-is_custom="value_id.is_custom"
|
||||
t-att-data-is_single_and_custom="single_and_custom"/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http, fields
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ class ProductConfiguratorController(http.Controller):
|
||||
to_currency = product_template.currency_id
|
||||
pricelist = self._get_pricelist(pricelist_id)
|
||||
|
||||
product_combination = False
|
||||
attribute_value_ids = set(kw.get('product_template_attribute_value_ids', []))
|
||||
attribute_value_ids |= set(kw.get('product_no_variant_attribute_value_ids', []))
|
||||
if attribute_value_ids:
|
||||
product_combination = request.env['product.template.attribute.value'].browse(attribute_value_ids)
|
||||
|
||||
if pricelist:
|
||||
product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id)
|
||||
to_currency = pricelist.currency_id
|
||||
@@ -23,6 +29,7 @@ class ProductConfiguratorController(http.Controller):
|
||||
'to_currency': to_currency,
|
||||
'pricelist': pricelist,
|
||||
'add_qty': add_qty,
|
||||
'product_combination': product_combination
|
||||
})
|
||||
|
||||
@http.route(['/sale_product_configurator/show_optional_products'], type='json', auth="user", methods=['POST'])
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import sale_order_line
|
||||
from . import product
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import fields, models, api
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
@@ -12,3 +12,38 @@ class ProductTemplate(models.Model):
|
||||
string='Optional Products', help="Optional Products are suggested "
|
||||
"whenever the customer hits *Add to Cart* (cross-sell strategy, "
|
||||
"e.g. for computers: warranty, software, etc.).")
|
||||
has_configurable_attributes = fields.Boolean("Is a configurable product", compute='_compute_has_configurable_attributes', store=True)
|
||||
|
||||
@api.multi
|
||||
@api.depends('attribute_line_ids.value_ids.is_custom', 'attribute_line_ids.attribute_id.create_variant')
|
||||
def _compute_has_configurable_attributes(self):
|
||||
""" A product is considered configurable if:
|
||||
- It has dynamic attributes
|
||||
- It has any attribute line with at least 2 attribute values configured
|
||||
- It has at least one custom attribute value """
|
||||
for product in self:
|
||||
product.has_configurable_attributes = any(attribute.create_variant == 'dynamic' for attribute in product.mapped('attribute_line_ids.attribute_id')) \
|
||||
or any(len(attribute_line_id.value_ids) >= 2 for attribute_line_id in product.attribute_line_ids) \
|
||||
or any(attribute_value.is_custom for attribute_value in product.mapped('attribute_line_ids.value_ids'))
|
||||
|
||||
def get_single_product_variant(self):
|
||||
""" Method used by the product configurator to check if the product is configurable or not.
|
||||
|
||||
We need to open the product configurator if the product:
|
||||
- is configurable (see has_configurable_attributes)
|
||||
- has optional products """
|
||||
self.ensure_one()
|
||||
|
||||
if self.product_variant_count == 1 and not self.has_configurable_attributes:
|
||||
has_optional_products = False
|
||||
for optional_product in self.product_variant_id.optional_product_ids:
|
||||
if optional_product.has_dynamic_attributes() or optional_product.get_filtered_variants(self.product_variant_id):
|
||||
has_optional_products = True
|
||||
break
|
||||
|
||||
return {
|
||||
'product_id': self.product_variant_id.id,
|
||||
'has_optional_products': has_optional_products
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = "sale.order.line"
|
||||
|
||||
product_template_id = fields.Many2one('product.template', string='Product Template', related="product_id.product_tmpl_id", domain=[('sale_ok', '=', True)])
|
||||
is_configurable_product = fields.Boolean('Is the product configurable?', related="product_template_id.has_configurable_attributes")
|
||||
product_template_attribute_value_ids = fields.Many2many(related='product_id.product_template_attribute_value_ids', readonly=True)
|
||||
+148
-35
@@ -8,23 +8,64 @@ var OptionalProductsModal = require('sale_product_configurator.OptionalProductsM
|
||||
|
||||
var ProductConfiguratorFormController = FormController.extend({
|
||||
custom_events: _.extend({}, FormController.prototype.custom_events, {
|
||||
field_changed: '_onFieldChanged'
|
||||
field_changed: '_onFieldChanged',
|
||||
handle_add: '_handleAdd'
|
||||
}),
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this.$el.addClass('o_product_configurator');
|
||||
return this._super.apply(this, arguments);
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
self.$el.addClass('o_product_configurator');
|
||||
});
|
||||
},
|
||||
/**
|
||||
* We need to override the default click behavior for our "Add" button
|
||||
* because there is a possibility that this product has optional products.
|
||||
* If so, we need to display an extra modal to choose the options.
|
||||
* We need to first load the template of the selected product and then render the content
|
||||
* to avoid a flicker when the modal is opened.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
willStart: function (){
|
||||
var def = this._super.apply(this, arguments);
|
||||
if (this.initialState.data.product_template_id) {
|
||||
return this._configureProduct(
|
||||
this.initialState.data.product_template_id.data.id
|
||||
).then(function () {
|
||||
return def;
|
||||
});
|
||||
}
|
||||
|
||||
return def;
|
||||
},
|
||||
/**
|
||||
* Showing this window is useless for configuratorMode 'options' as this form view
|
||||
* is used as a bridge between SO lines and optional products.
|
||||
*
|
||||
* Placed here because it's the only method that is called after the modal is rendered.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
renderButtons: function () {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
if (this.renderer.state.context.configuratorMode === 'options') {
|
||||
this.$el.closest('.modal').addClass('d-none');
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
|
||||
/**
|
||||
* We need to override the default click behavior for our "Add" button
|
||||
* because there is a possibility that this product has optional products.
|
||||
* If so, we need to display an extra modal to choose the options.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
_onButtonClicked: function (event) {
|
||||
if (event.stopPropagation){
|
||||
event.stopPropagation();
|
||||
@@ -37,16 +78,15 @@ var ProductConfiguratorFormController = FormController.extend({
|
||||
.parents('.modal')
|
||||
.find('.o_sale_product_configurator_add')
|
||||
.hasClass('disabled')){
|
||||
this._handleAdd(this.$el);
|
||||
this._handleAdd();
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* This is overridden to allow catching the "select" event on our product template select field.
|
||||
* This will not work anymore if more fields are added to the form.
|
||||
* TODO awa: Find a better way to catch that event.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onFieldChanged: function (event) {
|
||||
this._super.apply(this, arguments);
|
||||
@@ -59,23 +99,37 @@ var ProductConfiguratorFormController = FormController.extend({
|
||||
return;
|
||||
}
|
||||
|
||||
this.$el.parents('.modal').find('.o_sale_product_configurator_add').removeClass('disabled');
|
||||
|
||||
this._rpc({
|
||||
route: '/product_configurator/configure',
|
||||
params: {
|
||||
product_id: product_id,
|
||||
pricelist_id: this.renderer.pricelistId
|
||||
}
|
||||
}).then(function (configurator) {
|
||||
self.renderer.renderConfigurator(configurator);
|
||||
});
|
||||
this._configureProduct(event.data.changes.product_template_id.id)
|
||||
.then(function (){
|
||||
self.renderer.renderConfigurator(self.renderer.configuratorHtml);
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renders the "variants" part of the wizard
|
||||
*
|
||||
* @param {integer} productTemplateId
|
||||
*/
|
||||
_configureProduct: function (productTemplateId) {
|
||||
var self = this;
|
||||
var data = this.renderer.state.data;
|
||||
return this._rpc({
|
||||
route: '/sale_product_configurator/configure',
|
||||
params: {
|
||||
product_id: productTemplateId,
|
||||
pricelist_id: this.renderer.pricelistId,
|
||||
add_qty: data.quantity,
|
||||
product_template_attribute_value_ids: this._getAttributeValueIds(
|
||||
data.product_template_attribute_value_ids
|
||||
),
|
||||
product_no_variant_attribute_value_ids: this._getAttributeValueIds(
|
||||
data.product_no_variant_attribute_value_ids
|
||||
)
|
||||
}
|
||||
}).then(function (configurator) {
|
||||
self.renderer.configuratorHtml = configurator;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* When the user adds a product that has optional products, we need to display
|
||||
* a window to allow the user to choose these extra options.
|
||||
@@ -83,21 +137,31 @@ var ProductConfiguratorFormController = FormController.extend({
|
||||
* This will also create the product if it's in "dynamic" mode
|
||||
* (see product_attribute.create_variant)
|
||||
*
|
||||
* If "self.renderer.state.context.configuratorMode" is 'edit', this will only send
|
||||
* the main product with its changes.
|
||||
*
|
||||
* As opposed to the 'add' mode that will add the main product AND all the configured optional products.
|
||||
*
|
||||
* A third mode, 'options', is available for products that don't have a configuration but have
|
||||
* optional products to select. This will bypass the configuration step and open the
|
||||
* options modal directly.
|
||||
*
|
||||
* @private
|
||||
* @param {$.Element} $modal
|
||||
*/
|
||||
_handleAdd: function ($modal) {
|
||||
_handleAdd: function () {
|
||||
var self = this;
|
||||
var $modal = this.$el;
|
||||
var productSelector = [
|
||||
'input[type="hidden"][name="product_id"]',
|
||||
'input[type="radio"][name="product_id"]:checked'
|
||||
];
|
||||
|
||||
var productId = parseInt($modal.find(productSelector.join(', ')).first().val(), 10);
|
||||
var productTemplateId = $modal.find('.product_template_id').val();
|
||||
this.renderer.selectOrCreateProduct(
|
||||
$modal,
|
||||
productId,
|
||||
$modal.find('.product_template_id').val(),
|
||||
productTemplateId,
|
||||
false
|
||||
).then(function (productId) {
|
||||
$modal.find(productSelector.join(', ')).val(productId);
|
||||
@@ -116,37 +180,51 @@ var ProductConfiguratorFormController = FormController.extend({
|
||||
|
||||
self.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: productCustomVariantValues,
|
||||
no_variant_attribute_values: noVariantAttributeValues
|
||||
};
|
||||
|
||||
if (self.renderer.state.context.configuratorMode === 'edit') {
|
||||
// edit mode only takes care of main product
|
||||
self._onAddRootProductOnly();
|
||||
return;
|
||||
}
|
||||
|
||||
self.optionalProductsModal = new OptionalProductsModal($('body'), {
|
||||
rootProduct: self.rootProduct,
|
||||
pricelistId: self.renderer.pricelistId,
|
||||
okButtonText: _t('Confirm'),
|
||||
cancelButtonText: _t('Back'),
|
||||
title: _t('Configure')
|
||||
title: _t('Configure'),
|
||||
context: self.initialState.context,
|
||||
previousModalHeight: self.$el.closest('.modal-content').height()
|
||||
}).open();
|
||||
|
||||
self.optionalProductsModal.on('options_empty', null,
|
||||
self._onModalOptionsEmpty.bind(self));
|
||||
// no optional products found for this product, only add the root product
|
||||
self._onAddRootProductOnly.bind(self));
|
||||
|
||||
self.optionalProductsModal.on('update_quantity', null,
|
||||
self._onOptionsUpdateQuantity.bind(self));
|
||||
|
||||
self.optionalProductsModal.on('confirm', null,
|
||||
self._onModalConfirm.bind(self));
|
||||
|
||||
self.optionalProductsModal.on('closed', null,
|
||||
self._onModalClose.bind(self));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* No optional products found for this product, only add the root product
|
||||
* Add root product only and forget optional products.
|
||||
* Used when product has no optional products and in 'edit' mode.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onModalOptionsEmpty: function () {
|
||||
_onAddRootProductOnly: function () {
|
||||
this._addProducts([this.rootProduct]);
|
||||
},
|
||||
|
||||
@@ -156,9 +234,23 @@ var ProductConfiguratorFormController = FormController.extend({
|
||||
* @private
|
||||
*/
|
||||
_onModalConfirm: function () {
|
||||
this._wasConfirmed = true;
|
||||
this._addProducts(this.optionalProductsModal.getSelectedProducts());
|
||||
},
|
||||
|
||||
/**
|
||||
* When the optional products modal is closed (and not confirmed) on 'options' mode,
|
||||
* this window should also be closed immediately.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onModalClose: function () {
|
||||
if (this.renderer.state.context.configuratorMode === 'options'
|
||||
&& this._wasConfirmed !== true) {
|
||||
this._onAddRootProductOnly();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update product configurator form
|
||||
* when quantity is updated in the optional products window
|
||||
@@ -176,7 +268,7 @@ var ProductConfiguratorFormController = FormController.extend({
|
||||
/**
|
||||
* This triggers the close action for the window and
|
||||
* adds the product as the "infos" parameter.
|
||||
* It will allow the caller (typically the SO line form) of this window
|
||||
* It will allow the caller (typically the product_configurator widget) of this window
|
||||
* to handle the added products.
|
||||
*
|
||||
* @private
|
||||
@@ -189,10 +281,31 @@ var ProductConfiguratorFormController = FormController.extend({
|
||||
* see product_configurator_mixin.getNoVariantAttributeValues
|
||||
*/
|
||||
_addProducts: function (products) {
|
||||
this.do_action({type: 'ir.actions.act_window_close', infos: products});
|
||||
this.do_action({type: 'ir.actions.act_window_close', infos: {
|
||||
mainProduct: products[0],
|
||||
options: products.slice(1)
|
||||
}});
|
||||
},
|
||||
/**
|
||||
* Extracts the ids from the passed attributeValueIds and returns them
|
||||
* as a plain array.
|
||||
*
|
||||
* @param {Array} attributeValueIds
|
||||
*/
|
||||
_getAttributeValueIds(attributeValueIds) {
|
||||
if (!attributeValueIds || attributeValueIds.length === 0){
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = [];
|
||||
_.each(attributeValueIds.data, function (attributeValue) {
|
||||
result.push(attributeValue.data.id);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
return ProductConfiguratorFormController;
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,30 +12,23 @@ var optionalProductsMap = {};
|
||||
var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixin, {
|
||||
events: _.extend({}, Dialog.prototype.events, ProductConfiguratorMixin.events, {
|
||||
'click a.js_add, a.js_remove': '_onAddOrRemoveOption',
|
||||
'change .in_cart.main_product input.js_quantity': '_onChangeQuantity'
|
||||
'change .in_cart.main_product input.js_quantity': '_onChangeQuantity',
|
||||
'change .js_raw_price': '_computePriceTotal'
|
||||
}),
|
||||
/**
|
||||
* Initializes the optional products modal
|
||||
*
|
||||
* If the "isWebsite" param is true, will also disable the following events:
|
||||
* - change [data-attribute_exclusions]
|
||||
* - click button.js_add_cart_json
|
||||
*
|
||||
* This has to be done because those events are already registered at the "website_sale"
|
||||
* component level.
|
||||
* This modal is part of the form that has these events registered and we
|
||||
* want to avoid duplicates.
|
||||
*
|
||||
* @override
|
||||
* @param {$.Element} parent The parent container
|
||||
* @param {Object} params
|
||||
* @param {integer} params.pricelistId
|
||||
* @param {boolean} params.isWebsite If we're on a web shop page, we need some
|
||||
* custom behavior
|
||||
* @param {string} params.okButtonText The text to apply on the "ok" button, typically
|
||||
* "Add" for the sale order and "Proceed to checkout" on the web shop
|
||||
* "Add" for the sale order and "Proceed to checkout" on the web shop
|
||||
* @param {string} params.cancelButtonText same as "params.okButtonText" but
|
||||
* for the cancel button
|
||||
* @param {integer} params.previousModalHeight used to configure a min height on the modal-content.
|
||||
* This parameter is provided by the product configurator to "cover" its modal by making
|
||||
* this one big enough. This way the user can't see multiple buttons (which can be confusing).
|
||||
* @param {Object} params.rootProduct The root product of the optional products window
|
||||
* @param {integer} params.rootProduct.product_id
|
||||
* @param {integer} params.rootProduct.quantity
|
||||
@@ -65,13 +58,19 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
|
||||
this.rootProduct = params.rootProduct;
|
||||
this.container = parent;
|
||||
this.pricelistId = params.pricelistId;
|
||||
this.isWebsite = params.isWebsite;
|
||||
this.dialogClass = 'oe_optional_products_modal' + (params.isWebsite ? ' oe_website_sale' : '');
|
||||
|
||||
if (this.isWebsite) {
|
||||
delete this.events['click button.js_add_cart_json'];
|
||||
}
|
||||
this.previousModalHeight = params.previousModalHeight;
|
||||
this.dialogClass = 'oe_optional_products_modal';
|
||||
this._productImageField = 'image_medium';
|
||||
|
||||
// reset any previously populated properties maps
|
||||
productNameMap = {};
|
||||
optionalProductsMap = {};
|
||||
|
||||
this._opened.then(function () {
|
||||
if (self.previousModalHeight) {
|
||||
self.$el.closest('.modal-content').css('min-height', self.previousModalHeight + 'px');
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
@@ -188,6 +187,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
|
||||
var noVariantAttributeValues = self.getNoVariantAttributeValues($(this));
|
||||
products.push({
|
||||
product_id: parseInt($item.find('input.product_id').val()),
|
||||
product_template_id: parseInt($item.find('input.product_template_id').val()),
|
||||
quantity: quantity,
|
||||
product_custom_attribute_values: productCustomVariantValues,
|
||||
no_variant_attribute_values: noVariantAttributeValues
|
||||
@@ -283,7 +283,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
|
||||
self._onRemoveOption($modal, $parent ,productTemplateId);
|
||||
}
|
||||
|
||||
self.computePriceTotal();
|
||||
self._computePriceTotal();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -470,11 +470,38 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
|
||||
|
||||
this.rootProduct.quantity = qty;
|
||||
|
||||
if (!this.isWebsite){
|
||||
if (this._triggerPriceUpdateOnChangeQuantity()){
|
||||
this.onChangeAddQuantity(ev);
|
||||
}
|
||||
|
||||
this.trigger('update_quantity', qty);
|
||||
},
|
||||
|
||||
/**
|
||||
* When a product is added or when the quantity is changed,
|
||||
* we need to refresh the total price row
|
||||
*/
|
||||
_computePriceTotal: function () {
|
||||
if (this.$modal.find('.js_price_total').length){
|
||||
var price = 0;
|
||||
var quantity = parseInt(this.$modal.find('input[name="add_qty"]').first().val());
|
||||
this.$modal.find('.js_product.in_cart').each(function (){
|
||||
price += parseFloat($(this).find('.js_raw_price').html()) * quantity;
|
||||
});
|
||||
|
||||
this.$modal.find('.js_price_total .oe_currency_value').text(
|
||||
this._priceToStr(parseFloat(price))
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Extension point for website_sale
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_triggerPriceUpdateOnChangeQuantity: function () {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ var ProductConfiguratorFormRenderer = FormRenderer.extend(ProductConfiguratorMix
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
self.$el.append($('<div>', {class: 'configurator_container'}));
|
||||
self.renderConfigurator(self.configuratorHtml);
|
||||
self._checkMode();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -46,6 +48,67 @@ var ProductConfiguratorFormRenderer = FormRenderer.extend(ProductConfiguratorMix
|
||||
$configuratorHtml.appendTo($configuratorContainer);
|
||||
|
||||
this.triggerVariantChange($configuratorContainer);
|
||||
this._applyCustomValues();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* If the configuratorMode in the given context is 'edit', we need to
|
||||
* hide the regular 'Add' button to replace it with an 'EDIT' button.
|
||||
*
|
||||
* If the configuratorMode is set to 'options', we will directly open the
|
||||
* options modal.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_checkMode: function () {
|
||||
if (this.state.context.configuratorMode === 'edit') {
|
||||
this.$('.o_sale_product_configurator_add').hide();
|
||||
this.$('.o_sale_product_configurator_edit').css('display', 'inline-block');
|
||||
} else if (this.state.context.configuratorMode === 'options') {
|
||||
this.trigger_up('handle_add');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Will fill the custom values input based on the provided initial configuration.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_applyCustomValues: function () {
|
||||
var self = this;
|
||||
var customValueIds = this.state.data.product_custom_attribute_value_ids;
|
||||
if (customValueIds) {
|
||||
_.each(customValueIds.data, function (customValue) {
|
||||
if (customValue.data.custom_value) {
|
||||
var attributeValueId = customValue.data.attribute_value_id.data.id;
|
||||
var $input = self._findRelatedAttributeValueInput(attributeValueId);
|
||||
$input
|
||||
.closest('li[data-attribute_id]')
|
||||
.find('.variant_custom_value')
|
||||
.val(customValue.data.custom_value);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Find the $.Element input/select related to that product.attribute.value
|
||||
*
|
||||
* @param {integer} attributeValueId
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_findRelatedAttributeValueInput: function (attributeValueId) {
|
||||
var selectors = [
|
||||
'ul.js_add_cart_variants input[data-value_id="' + attributeValueId + '"]',
|
||||
'ul.js_add_cart_variants option[data-value_id="' + attributeValueId + '"]'
|
||||
];
|
||||
|
||||
return this.$(selectors.join(', '));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -17,4 +17,4 @@ viewRegistry.add('product_configurator_form', ProductConfiguratorFormView);
|
||||
|
||||
return ProductConfiguratorFormView;
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
odoo.define('sale_product_configurator.product_configurator', function (require) {
|
||||
var relationalFields = require('web.relational_fields');
|
||||
var FieldsRegistry = require('web.field_registry');
|
||||
var core = require('web.core');
|
||||
var _t = core._t;
|
||||
|
||||
/**
|
||||
* The product configurator widget is a simple FieldMany2One that adds the capability
|
||||
* to configure a product_template_id using the product configurator wizard.
|
||||
*
|
||||
* !!! It should only be used on a product_template_id field !!!
|
||||
*/
|
||||
var ProductConfiguratorWidget = relationalFields.FieldMany2One.extend({
|
||||
events: _.extend({}, relationalFields.FieldMany2One.prototype.events, {
|
||||
'click .o_edit_product_configuration': '_onEditProductConfiguration'
|
||||
}),
|
||||
|
||||
/**
|
||||
* This method will check if the current product_template set on the SO line is configurable
|
||||
* -> If so, we add a 'Edit Configuration' button next to the dropdown.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var prom = this._super.apply(this, arguments);
|
||||
|
||||
var $inputDropdown = this.$('.o_input_dropdown');
|
||||
|
||||
if (this.recordData.is_configurable_product &&
|
||||
$inputDropdown.length !== 0 &&
|
||||
this.$('.o_edit_product_configuration').length === 0) {
|
||||
var $editConfigurationButton = $('<button>', {
|
||||
type: 'button',
|
||||
class: 'fa fa-pencil btn btn-secondary o_edit_product_configuration',
|
||||
tabindex: '-1',
|
||||
draggable: false,
|
||||
'aria-label': _t('Edit Configuration'),
|
||||
title: _t('Edit Configuration')
|
||||
});
|
||||
|
||||
$inputDropdown.after($editConfigurationButton);
|
||||
}
|
||||
return prom;
|
||||
},
|
||||
|
||||
/**
|
||||
* This method is overridden to check 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
|
||||
* @param {OdooEvent} event
|
||||
* {boolean} event.data.preventProductIdCheck prevent the product configurator widget
|
||||
* from looping forever when it needs to change the 'product_template_id'
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onFieldChanged: function (event) {
|
||||
var self = this;
|
||||
self.restoreProductTemplateId = self.recordData.product_template_id;
|
||||
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
var $inputDropdown = this.$('.o_input_dropdown');
|
||||
if (event.data.changes.product_template_id
|
||||
&& $inputDropdown.length !== 0 &&
|
||||
this.$('.o_edit_product_configuration').length !== 0){
|
||||
this.$('.o_edit_product_configuration').remove();
|
||||
}
|
||||
|
||||
if (!event.data.changes.product_template_id
|
||||
|| event.data.preventProductIdCheck){
|
||||
return;
|
||||
}
|
||||
|
||||
var productTemplateId = event.data.changes.product_template_id.id;
|
||||
if (productTemplateId){
|
||||
this._rpc({
|
||||
model: 'product.template',
|
||||
method: 'get_single_product_variant',
|
||||
args: [
|
||||
productTemplateId
|
||||
]
|
||||
}).then(function (result){
|
||||
if (result){
|
||||
self.trigger_up('field_changed', {
|
||||
dataPointID: event.data.dataPointID,
|
||||
changes: {
|
||||
product_id: {id: result.product_id},
|
||||
product_custom_attribute_value_ids: {
|
||||
operation: 'DELETE_ALL'
|
||||
}
|
||||
},
|
||||
onSuccess: function () {
|
||||
if (result.has_optional_products) {
|
||||
self._openProductConfigurator({
|
||||
configuratorMode: 'options',
|
||||
default_pricelist_id: self._getPricelistId(),
|
||||
default_product_template_id: productTemplateId
|
||||
},
|
||||
event.data.dataPointID
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
self._onSimpleProductFound(result.product_id, event.data.dataPointID);
|
||||
} else {
|
||||
self._openProductConfigurator({
|
||||
configuratorMode: 'add',
|
||||
default_pricelist_id: self._getPricelistId(),
|
||||
default_product_template_id: productTemplateId
|
||||
},
|
||||
event.data.dataPointID
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hooking point for other modules
|
||||
*
|
||||
* @param {integer} productId
|
||||
* @param {string} dataPointID
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onSimpleProductFound: function (productId, dataPointID) {},
|
||||
|
||||
/**
|
||||
* 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'.
|
||||
* @param {string} dataPointId
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_openProductConfigurator: function (data, dataPointId) {
|
||||
var self = this;
|
||||
this.do_action('sale_product_configurator.sale_product_configurator_action', {
|
||||
additional_context: data,
|
||||
on_close: function (result) {
|
||||
if (result && result !== 'special'){
|
||||
self._addProducts(result, dataPointId);
|
||||
} else {
|
||||
if (self.restoreProductTemplateId) {
|
||||
self.trigger_up('field_changed', {
|
||||
dataPointID: dataPointId,
|
||||
preventProductIdCheck: true,
|
||||
changes: {
|
||||
product_template_id: self.restoreProductTemplateId.data
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 () {
|
||||
this._openProductConfigurator({
|
||||
configuratorMode: 'edit',
|
||||
default_product_template_id: this.recordData.product_template_id.data.id,
|
||||
default_pricelist_id: this._getPricelistId(),
|
||||
default_product_template_attribute_value_ids: this._convertFromMany2Many(
|
||||
this.recordData.product_template_attribute_value_ids
|
||||
),
|
||||
default_product_no_variant_attribute_value_ids: this._convertFromMany2Many(
|
||||
this.recordData.product_no_variant_attribute_value_ids
|
||||
),
|
||||
default_product_custom_attribute_value_ids: this._convertFromOne2Many(
|
||||
this.recordData.product_custom_attribute_value_ids
|
||||
),
|
||||
default_quantity: this.recordData.product_uom_qty
|
||||
},
|
||||
this.dataPointID
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
var self = this;
|
||||
this.trigger_up('field_changed', {
|
||||
dataPointID: dataPointId,
|
||||
preventProductIdCheck: true,
|
||||
changes: this._getMainProductChanges(result.mainProduct),
|
||||
onSuccess: function () {
|
||||
if (result.options) {
|
||||
var parentList = self.getParent();
|
||||
self.trigger_up('add_record', {
|
||||
context: self._productsToRecords(result.options),
|
||||
forceEditable: 'bottom',
|
||||
allowWarning: true,
|
||||
onSuccess: function (){
|
||||
parentList.unselectRow();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 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_attribute_value_id: customValue.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 res_ids = _.map(noVariantAttributeValues, function (noVariantValue) {
|
||||
return {id: parseInt(noVariantValue.value)};
|
||||
});
|
||||
|
||||
noVariantCommands.push({
|
||||
operation: 'ADD_M2M',
|
||||
ids: res_ids
|
||||
});
|
||||
}
|
||||
|
||||
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 default_product_no_variant_attribute_values = [];
|
||||
_.each(product.no_variant_attribute_values, function (attribute_value) {
|
||||
default_product_no_variant_attribute_values.push(
|
||||
[4, parseInt(attribute_value.value)]
|
||||
);
|
||||
});
|
||||
record['default_product_no_variant_attribute_value_ids']
|
||||
= default_product_no_variant_attribute_values;
|
||||
}
|
||||
|
||||
if (product.product_custom_attribute_values) {
|
||||
var default_custom_attribute_values = [];
|
||||
_.each(product.product_custom_attribute_values, function (attribute_value) {
|
||||
default_custom_attribute_values.push(
|
||||
[0, 0, {
|
||||
attribute_value_id: attribute_value.attribute_value_id,
|
||||
custom_value: attribute_value.custom_value
|
||||
}]
|
||||
);
|
||||
});
|
||||
record['default_product_custom_attribute_value_ids']
|
||||
= default_custom_attribute_values;
|
||||
}
|
||||
|
||||
records.push(record);
|
||||
});
|
||||
|
||||
return records;
|
||||
},
|
||||
|
||||
/**
|
||||
* Will convert the values contained in the recordData parameter to
|
||||
* a list of '4' operations that can be passed as a 'default_' parameter.
|
||||
*
|
||||
* @param {Object} recordData
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_convertFromMany2Many: function (recordData) {
|
||||
if (recordData) {
|
||||
var convertedValues = [];
|
||||
_.each(recordData.res_ids, function (res_id) {
|
||||
convertedValues.push([4, parseInt(res_id)]);
|
||||
});
|
||||
|
||||
return convertedValues;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Will convert the values contained in the recordData parameter to
|
||||
* a list of '0' or '4' operations (based on wether the record is already persisted or not)
|
||||
* that can be passed as a 'default_' parameter.
|
||||
*
|
||||
* @param {Object} recordData
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_convertFromOne2Many: function (recordData) {
|
||||
if (recordData) {
|
||||
var convertedValues = [];
|
||||
_.each(recordData.res_ids, function (res_id) {
|
||||
if (isNaN(res_id)){
|
||||
_.each(recordData.data, function (record) {
|
||||
if (record.ref === res_id) {
|
||||
convertedValues.push([0, 0, {
|
||||
attribute_value_id: record.data.attribute_value_id.data.id,
|
||||
custom_value: record.data.custom_value
|
||||
}]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
convertedValues.push([4, res_id]);
|
||||
}
|
||||
});
|
||||
|
||||
return convertedValues;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
FieldsRegistry.add('product_configurator', ProductConfiguratorWidget);
|
||||
|
||||
return ProductConfiguratorWidget;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
odoo.define("sale_product_configurator.tour", function (require) {
|
||||
"use strict";
|
||||
/**
|
||||
* We need to replace the trigger of the product selection step because the
|
||||
* "sale_product_configurator" module replaces the "product_id" field with
|
||||
* a "product_template_id" field.
|
||||
*/
|
||||
var tour = require('web_tour.tour');
|
||||
require('sale.tour');
|
||||
|
||||
var productSelectionStepIndex = _.findIndex(tour.tours.sale_tour.steps, function (step) {
|
||||
return (step.id === 'product_selection_step');
|
||||
});
|
||||
|
||||
var productSelectionStep = tour.tours.sale_tour.steps[productSelectionStepIndex];
|
||||
productSelectionStep.trigger = ".o_form_editable .o_field_many2one[name='product_template_id']";
|
||||
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
.o_list_view {
|
||||
.o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier) {
|
||||
.o_field_widget .o_edit_product_configuration {
|
||||
padding: 0;
|
||||
background-color: inherit;
|
||||
margin-left: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +1,262 @@
|
||||
odoo.define('sale.product.configurator.tests', function (require) {
|
||||
"use strict";
|
||||
"use strict";
|
||||
|
||||
var FormView = require('web.FormView');
|
||||
var ProductConfiguratorFormView = require('sale_product_configurator.ProductConfiguratorFormView');
|
||||
var testUtils = require('web.test_utils');
|
||||
var createView = testUtils.createView;
|
||||
var FormView = require('web.FormView');
|
||||
var ProductConfiguratorFormView = require('sale_product_configurator.ProductConfiguratorFormView');
|
||||
var testUtils = require('web.test_utils');
|
||||
var createView = testUtils.createView;
|
||||
|
||||
var 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="Configure a product" context="{\'open_product_configurator\': True}"/>' +
|
||||
'<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"/><field name="product_uom_qty"/>' +
|
||||
'</tree>' +
|
||||
'</field>' +
|
||||
'</sheet>' +
|
||||
'</form>';
|
||||
};
|
||||
var 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'}
|
||||
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'
|
||||
},
|
||||
records: [{
|
||||
id: 42,
|
||||
display_name: "Customizable Desk"
|
||||
}]
|
||||
},
|
||||
product: {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
sale_order_line: {
|
||||
string: 'lines',
|
||||
type: 'one2many',
|
||||
relation: 'sale_order_line'
|
||||
},
|
||||
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_id: {
|
||||
string: 'product',
|
||||
type: 'many2one',
|
||||
relation: 'product'
|
||||
},
|
||||
product_uom_qty: {type: 'integer'}
|
||||
}
|
||||
},
|
||||
sale_product_configurator: {
|
||||
fields: {
|
||||
product_template_id: {
|
||||
string: 'product',
|
||||
type: 'many2one',
|
||||
relation: 'product_template'
|
||||
}
|
||||
}
|
||||
},
|
||||
sale_order_line: {
|
||||
fields: {
|
||||
product_template_id: {
|
||||
string: 'product template',
|
||||
type: 'many2one',
|
||||
relation: 'product_template'
|
||||
},
|
||||
records: [{
|
||||
product_template_id: 42
|
||||
}]
|
||||
},
|
||||
pricelist: {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
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'}
|
||||
}
|
||||
},
|
||||
product_custom_attribute_value: {
|
||||
fields: {
|
||||
id: {type: 'integer'},
|
||||
sale_order_line_id: {
|
||||
string: 'sale order line',
|
||||
type: 'many2one',
|
||||
relation: 'sale_order_line'
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
}, function (){
|
||||
QUnit.test('click on "Configure a product" and check for form loading', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: getArch(),
|
||||
mockRPC: function (route) {
|
||||
if (route === '/web/dataset/call_kw/ir.model.data/xmlid_to_res_id') {
|
||||
assert.ok(true);
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
sale_product_configurator: {
|
||||
fields: {
|
||||
product_template_id: {
|
||||
string: 'product',
|
||||
type: 'many2one',
|
||||
relation: 'product_template'
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsOnce(form, "a:contains('Configure a product')");
|
||||
|
||||
await testUtils.dom.click(form.$("a:contains('Configure a product')"));
|
||||
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);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: getArch(),
|
||||
});
|
||||
|
||||
var 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('Select a product in the list and check for template loading', async function (assert){
|
||||
assert.expect(1);
|
||||
|
||||
var product_configurator_form = await createView({
|
||||
View: ProductConfiguratorFormView,
|
||||
model: 'sale_product_configurator',
|
||||
data: this.data,
|
||||
arch:
|
||||
'<form js_class="product_configurator_form">' +
|
||||
'<group>' +
|
||||
'<field name="product_template_id" class="oe_product_configurator_product_template_id" />' +
|
||||
'</group>' +
|
||||
'<footer>' +
|
||||
'<button string="Add" class="btn-primary o_sale_product_configurator_add disabled"/>' +
|
||||
'<button string="Cancel" class="btn-secondary" special="cancel"/>' +
|
||||
'</footer>' +
|
||||
'</form>',
|
||||
mockRPC: function (route) {
|
||||
if (route === '/product_configurator/configure') {
|
||||
assert.ok(true);
|
||||
return Promise.resolve('<div>plop</div>');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
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'
|
||||
}
|
||||
});
|
||||
await testUtils.dom.click(product_configurator_form.$('.o_input'));
|
||||
await testUtils.dom.click($("ul.ui-autocomplete li a:contains('Customizable Desk')").mouseenter());
|
||||
product_configurator_form.destroy();
|
||||
},
|
||||
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);
|
||||
|
||||
var 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) {
|
||||
assert.expect(2);
|
||||
|
||||
var 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);
|
||||
},
|
||||
intercepts: {
|
||||
do_action: function (ev) {
|
||||
if (ev.data.action === 'sale_product_configurator.sale_product_configurator_action') {
|
||||
assert.ok(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
await testUtils.dom.click(form.$("a:contains('Add a product')"));
|
||||
await testUtils.fields.many2one.searchAndClickItem("product_template_id", {item: 'Customizable Desk'});
|
||||
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);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: getArch()
|
||||
});
|
||||
|
||||
var 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('Select a product in the list and check for template loading', async function (assert){
|
||||
assert.expect(1);
|
||||
|
||||
var product_configurator_form = await createView({
|
||||
View: ProductConfiguratorFormView,
|
||||
model: 'sale_product_configurator',
|
||||
data: this.data,
|
||||
arch:
|
||||
'<form js_class="product_configurator_form">' +
|
||||
'<group>' +
|
||||
'<field name="product_template_id" class="oe_product_configurator_product_template_id" />' +
|
||||
'<field name="product_template_attribute_value_ids" invisible="1" />' +
|
||||
'<field name="product_no_variant_attribute_value_ids" invisible="1" />' +
|
||||
'<field name="product_custom_attribute_value_ids" invisible="1" />' +
|
||||
'</group>' +
|
||||
'<footer>' +
|
||||
'<button string="Add" class="btn-primary o_sale_product_configurator_add disabled"/>' +
|
||||
'<button string="Cancel" class="btn-secondary" special="cancel"/>' +
|
||||
'</footer>' +
|
||||
'</form>',
|
||||
mockRPC: function (route) {
|
||||
if (route === '/sale_product_configurator/configure') {
|
||||
assert.ok(true);
|
||||
return Promise.resolve('<div>plop</div>');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
await testUtils.dom.click(product_configurator_form.$('.o_input'));
|
||||
await testUtils.dom.click($("ul.ui-autocomplete li a:contains('Customizable Desk')").mouseenter());
|
||||
product_configurator_form.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+11
-6
@@ -24,15 +24,20 @@ tour.register('sale_product_configurator_advanced_tour', {
|
||||
trigger: ".ui-menu-item > a:contains('Tajine Saucisse')",
|
||||
auto: true,
|
||||
}, {
|
||||
trigger: "a:contains('Configure a product')",
|
||||
trigger: "a:contains('Add a product')",
|
||||
extra_trigger: ".o_field_widget[name=pricelist_id] > .o_external_button", // Wait for pricelist (onchange_partner_id)
|
||||
}, {
|
||||
trigger: '.o_product_configurator .o_input_dropdown input',
|
||||
run: 'click'
|
||||
trigger: 'div[name="product_template_id"] input',
|
||||
run: function (){
|
||||
var $input = $('div[name="product_template_id"] input');
|
||||
$input.click();
|
||||
$input.val('Custo');
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
$input.trigger(keyDownEvent);
|
||||
}
|
||||
}, {
|
||||
trigger: 'li a:contains("Customizable Desk")',
|
||||
in_modal: false,
|
||||
extra_trigger: 'ul.ui-autocomplete',
|
||||
trigger: 'ul.ui-autocomplete a:contains("Customizable Desk")',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: 'span:contains("Custom")',
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
odoo.define('sale.product_configurator_edition_tour', function (require) {
|
||||
"use strict";
|
||||
|
||||
var tour = require('web_tour.tour');
|
||||
|
||||
tour.register('sale_product_configurator_edition_tour', {
|
||||
url: "/web",
|
||||
test: true,
|
||||
}, [tour.STEPS.SHOW_APPS_MENU_ITEM, {
|
||||
trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
|
||||
edition: 'community'
|
||||
}, {
|
||||
trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
|
||||
edition: 'enterprise'
|
||||
}, {
|
||||
trigger: ".o_list_button_add",
|
||||
extra_trigger: ".o_sale_order"
|
||||
}, {
|
||||
trigger: "a:contains('Add a product')"
|
||||
}, {
|
||||
trigger: 'div[name="product_template_id"] input',
|
||||
run: function (){
|
||||
var $input = $('div[name="product_template_id"] input');
|
||||
$input.click();
|
||||
$input.val('Custo');
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
$input.trigger(keyDownEvent);
|
||||
}
|
||||
}, {
|
||||
trigger: 'ul.ui-autocomplete a:contains("Customizable Desk")',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: '.configurator_container span:contains("Steel")',
|
||||
run: function () {
|
||||
$('input.product_id').change(function () {
|
||||
$('.o_sale_product_configurator_add').attr('request_count', 1);
|
||||
});
|
||||
}
|
||||
}, {
|
||||
trigger: '.configurator_container span:contains("Aluminium")'
|
||||
}, {
|
||||
trigger: '.o_sale_product_configurator_add[request_count="1"]',
|
||||
run: function (){} // used to sync with "get_combination_info" completion
|
||||
}, {
|
||||
trigger: '.o_sale_product_configurator_add:not(.disabled)'
|
||||
}, {
|
||||
trigger: 'button span:contains(Confirm)',
|
||||
extra_trigger: '.oe_optional_products_modal',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("Customizable Desk (Aluminium, White)")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
run: function (){} // check added product
|
||||
}, {
|
||||
trigger: 'td.o_product_configurator_cell',
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration',
|
||||
}, {
|
||||
trigger: '.configurator_container li.js_attribute_value:has(span:contains("Aluminium")) input:checked',
|
||||
run: function (){} // check updated legs
|
||||
}, {
|
||||
trigger: 'span.oe_currency_value:contains("800")',
|
||||
run: function (){} // check updated price
|
||||
}, {
|
||||
trigger: '.configurator_container span:contains("Steel")',
|
||||
run: function () {
|
||||
$('input.product_id').change(function () {
|
||||
if ($('.o_sale_product_configurator_edit').attr('request_count')) {
|
||||
$('.o_sale_product_configurator_edit').attr('request_count',
|
||||
parseInt($('.o_sale_product_configurator_edit').attr('request_count')) + 1);
|
||||
} else {
|
||||
$('.o_sale_product_configurator_edit').attr('request_count', 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, {
|
||||
trigger: '.configurator_container span:contains("Custom")',
|
||||
run: function () {
|
||||
// FIXME awa: since jquery3 update it doesn't "click"
|
||||
// on the element without this run (and 'run: "click"'
|
||||
// doesn't work either)
|
||||
$('.configurator_container span:contains("Custom")').click();
|
||||
}
|
||||
}, {
|
||||
trigger: '.configurator_container .variant_custom_value',
|
||||
run: 'text nice custom value'
|
||||
}, {
|
||||
trigger: 'input[data-value_name="Black"]',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: '.o_sale_product_configurator_edit[request_count="2"]',
|
||||
run: function (){} // used to sync with "get_combination_info" completion
|
||||
}, {
|
||||
trigger: '.o_sale_product_configurator_edit',
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("Customizable Desk (Custom, Black)")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
run: function (){} // check updated product
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("Custom: nice custom value")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
run: function (){} // check custom value
|
||||
}, {
|
||||
trigger: 'td.o_product_configurator_cell',
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration',
|
||||
}, {
|
||||
trigger: '.configurator_container .variant_custom_value',
|
||||
run: 'text another nice custom value'
|
||||
}, {
|
||||
trigger: '.o_sale_product_configurator_edit',
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("Custom: another nice custom value")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
run: function (){} // check custom value
|
||||
}, {
|
||||
trigger: 'td.o_product_configurator_cell',
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration',
|
||||
}, {
|
||||
trigger: '.configurator_container span:contains("Steel")',
|
||||
run: function () {
|
||||
$('input.product_id').change(function () {
|
||||
$('.o_sale_product_configurator_edit').attr('request_count', 1);
|
||||
});
|
||||
}
|
||||
}, {
|
||||
trigger: '.configurator_container span:contains("Steel")',
|
||||
run: function () {
|
||||
// FIXME awa: since jquery3 update it doesn't "click"
|
||||
// on the element without this run (and 'run: "click"'
|
||||
// doesn't work either)
|
||||
$('.configurator_container span:contains("Steel")').click();
|
||||
}
|
||||
}, {
|
||||
trigger: '.o_sale_product_configurator_edit[request_count="1"]',
|
||||
run: function (){} // used to sync with "get_combination_info" completion
|
||||
}, {
|
||||
trigger: '.configurator_container button.js_add_cart_json:has(.fa-plus)',
|
||||
}, {
|
||||
trigger: '.o_sale_product_configurator_edit',
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("2.000")',
|
||||
run: function (){} // check quantity
|
||||
}, {
|
||||
trigger: 'td.o_product_configurator_cell',
|
||||
run: function () {
|
||||
// used to check that the description does not contain a custom value anymore
|
||||
if ($('td.o_data_cell:contains("Custom: another nice custom value")').length === 0){
|
||||
$('td.o_data_cell:contains("Customizable Desk (Steel, Black)")').html('tour success');
|
||||
}
|
||||
}
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("tour success")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
run: function (){}
|
||||
}]);
|
||||
|
||||
});
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
odoo.define('sale.product_configurator_optional_products_tour', function (require) {
|
||||
"use strict";
|
||||
|
||||
var tour = require('web_tour.tour');
|
||||
|
||||
tour.register('sale_product_configurator_optional_products_tour', {
|
||||
url: "/web",
|
||||
test: true,
|
||||
}, [tour.STEPS.SHOW_APPS_MENU_ITEM, {
|
||||
trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
|
||||
edition: 'community'
|
||||
}, {
|
||||
trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
|
||||
edition: 'enterprise'
|
||||
}, {
|
||||
trigger: ".o_list_button_add",
|
||||
extra_trigger: ".o_sale_order"
|
||||
}, {
|
||||
trigger: "a:contains('Add a product')"
|
||||
}, {
|
||||
trigger: 'div[name="product_template_id"] input',
|
||||
run: function (){
|
||||
var $input = $('div[name="product_template_id"] input');
|
||||
$input.click();
|
||||
$input.val('Office Chair Black');
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
$input.trigger(keyDownEvent);
|
||||
}
|
||||
}, {
|
||||
trigger: 'ul.ui-autocomplete a:contains("Office Chair Black")',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: '.js_add'
|
||||
}, {
|
||||
trigger: '.js_add_cart_json .fa-plus'
|
||||
}, {
|
||||
trigger: 'button span:contains(Confirm)',
|
||||
extra_trigger: '.oe_optional_products_modal',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: 'tr:has(td.o_data_cell:contains("Office Chair Black")) td.o_data_cell:contains("2.0")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
run: function (){} // check added product
|
||||
}, {
|
||||
trigger: 'tr:has(td.o_data_cell:contains("Chair floor protection")) td.o_data_cell:contains("2.0")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
run: function (){} // check added product
|
||||
}]);
|
||||
|
||||
});
|
||||
+12
-9
@@ -40,17 +40,20 @@ tour.STEPS.SHOW_APPS_MENU_ITEM,
|
||||
trigger: 'div.o_with_button[name="pricelist_id"]',
|
||||
run: function () {},
|
||||
}, {
|
||||
content: "open product configurator",
|
||||
trigger: 'a:contains("Configure a product")',
|
||||
trigger: "a:contains('Add a product')"
|
||||
}, {
|
||||
content: "open product selector",
|
||||
trigger: '.o_product_configurator .o_input_dropdown input',
|
||||
trigger: 'div[name="product_template_id"] input',
|
||||
run: function (){
|
||||
var $input = $('div[name="product_template_id"] input');
|
||||
$input.click();
|
||||
$input.val('Custo');
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
$input.trigger(keyDownEvent);
|
||||
}
|
||||
}, {
|
||||
trigger: 'ul.ui-autocomplete a:contains("Customizable Desk")',
|
||||
run: 'click'
|
||||
}, {
|
||||
content: "select desk",
|
||||
trigger: 'li a:contains("Customizable Desk")',
|
||||
in_modal: false,
|
||||
extra_trigger: 'ul.ui-autocomplete',
|
||||
}, {
|
||||
content: "check price is correct (USD)",
|
||||
trigger: 'span.oe_currency_value:contains("750.00")',
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
odoo.define('sale.product_configurator_single_custom_attribute_tour', function (require) {
|
||||
"use strict";
|
||||
|
||||
var tour = require('web_tour.tour');
|
||||
|
||||
tour.register('sale_product_configurator_single_custom_attribute_tour', {
|
||||
url: "/web",
|
||||
test: true,
|
||||
}, [tour.STEPS.SHOW_APPS_MENU_ITEM, {
|
||||
trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
|
||||
edition: 'community'
|
||||
}, {
|
||||
trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
|
||||
edition: 'enterprise'
|
||||
}, {
|
||||
trigger: ".o_list_button_add",
|
||||
extra_trigger: ".o_sale_order"
|
||||
}, {
|
||||
trigger: "a:contains('Add a product')"
|
||||
}, {
|
||||
trigger: 'div[name="product_template_id"] input',
|
||||
run: function (){
|
||||
var $input = $('div[name="product_template_id"] input');
|
||||
$input.click();
|
||||
$input.val('Custo');
|
||||
// fake keydown to trigger search
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
$input.trigger(keyDownEvent);
|
||||
}
|
||||
}, {
|
||||
trigger: 'ul.ui-autocomplete a:contains("Customizable Desk")',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: '.configurator_container span:contains("Aluminium")',
|
||||
run: function () {
|
||||
// used to check that the radio is NOT rendered
|
||||
if ($('.configurator_container ul[data-attribute_id].d-none input[data-value_name="single product attribute value"]').length === 1) {
|
||||
$('.configurator_container').addClass('tour_success');
|
||||
}
|
||||
}
|
||||
}, {
|
||||
trigger: '.configurator_container.tour_success',
|
||||
run: function () {
|
||||
//check
|
||||
}
|
||||
}, {
|
||||
trigger: '.configurator_container .variant_custom_value',
|
||||
run: 'text great single custom value'
|
||||
}, {
|
||||
trigger: '.o_sale_product_configurator_add',
|
||||
}, {
|
||||
trigger: 'button span:contains(Confirm)',
|
||||
extra_trigger: '.oe_optional_products_modal',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("single product attribute value: great single custom value")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
run: function (){} // check custom value
|
||||
}, {
|
||||
trigger: 'td.o_product_configurator_cell',
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration',
|
||||
}, {
|
||||
trigger: '.configurator_container .variant_custom_value',
|
||||
run: function () {
|
||||
// check custom value initialized
|
||||
if ($('.configurator_container .variant_custom_value').val() === "great single custom value") {
|
||||
$('.configurator_container').addClass('tour_success_2');
|
||||
}
|
||||
}
|
||||
}, {
|
||||
trigger: '.configurator_container.tour_success_2',
|
||||
run: function () {
|
||||
//check
|
||||
}
|
||||
}]);
|
||||
|
||||
});
|
||||
@@ -19,25 +19,26 @@ tour.register('sale_product_configurator_tour', {
|
||||
trigger: ".o_list_button_add",
|
||||
extra_trigger: ".o_sale_order"
|
||||
}, {
|
||||
trigger: "a:contains('Configure a product')",
|
||||
trigger: "a:contains('Add a product')",
|
||||
}, {
|
||||
trigger: '.o_product_configurator .o_input_dropdown input',
|
||||
run: 'click'
|
||||
trigger: 'div[name="product_template_id"] input',
|
||||
run: function (){
|
||||
var $input = $('div[name="product_template_id"] input');
|
||||
$input.click();
|
||||
$input.val('Custo');
|
||||
// fake keydown to trigger search
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
$input.trigger(keyDownEvent);
|
||||
}
|
||||
}, {
|
||||
trigger: 'li a:contains("Customizable Desk")',
|
||||
in_modal: false,
|
||||
extra_trigger: 'ul.ui-autocomplete',
|
||||
trigger: 'ul.ui-autocomplete a:contains("Customizable Desk")',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: '.configurator_container span:contains("Steel")',
|
||||
run: function () {
|
||||
$('input.product_id').change(function () {
|
||||
var request_count = 0;
|
||||
if ($('.o_sale_product_configurator_add').attr('request_count')) {
|
||||
request_count = parseInt($('.o_sale_product_configurator_add').attr('request_count'));
|
||||
}
|
||||
request_count++;
|
||||
$('.o_sale_product_configurator_add').attr('request_count', request_count);
|
||||
$('.o_sale_product_configurator_add').attr('request_count', 1);
|
||||
});
|
||||
}
|
||||
}, {
|
||||
@@ -47,11 +48,7 @@ tour.register('sale_product_configurator_tour', {
|
||||
trigger: 'span.oe_currency_value:contains("800.40")',
|
||||
run: function (){} // check updated price
|
||||
}, {
|
||||
trigger: 'input[data-value_name="Black"]'
|
||||
}, {
|
||||
trigger: 'input[data-value_name="White"]'
|
||||
}, {
|
||||
trigger: '.o_sale_product_configurator_add[request_count="3"]',
|
||||
trigger: '.o_sale_product_configurator_add[request_count="1"]',
|
||||
run: function (){} // used to sync with "get_combination_info" completion
|
||||
}, {
|
||||
trigger: '.o_sale_product_configurator_add:not(.disabled)'
|
||||
|
||||
@@ -7,7 +7,7 @@ import odoo.tests
|
||||
@odoo.tests.tagged('post_install', '-at_install')
|
||||
class TestUi(odoo.tests.HttpCase):
|
||||
def test_01_product_configurator(self):
|
||||
# To be able to test the product configurator, admin user must have access to "variants and options" feature, so we give him the right group for that
|
||||
# To be able to test the product configurator, admin user must have access to "variants" feature, so we give him the right group for that
|
||||
self.env.ref('base.user_admin').write({'groups_id': [(4, self.env.ref('product.group_product_variant').id)]})
|
||||
|
||||
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_tour.ready", login="admin")
|
||||
@@ -76,7 +76,7 @@ class TestUi(odoo.tests.HttpCase):
|
||||
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_advanced_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_advanced_tour.ready", login="admin")
|
||||
|
||||
def test_03_product_configurator_edition(self):
|
||||
# To be able to test the product configurator, admin user must have access to "variants and options" feature, so we give him the right group for that
|
||||
# To be able to test the product configurator, admin user must have access to "variants" feature, so we give him the right group for that
|
||||
self.env.ref('base.user_admin').write({'groups_id': [(4, self.env.ref('product.group_product_variant').id)]})
|
||||
|
||||
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_edition_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_edition_tour.ready", login="admin")
|
||||
@@ -156,4 +156,18 @@ class TestUi(odoo.tests.HttpCase):
|
||||
'compute_price': 'formula',
|
||||
})
|
||||
|
||||
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_pricelist_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_pricelist_tour.ready", login="admin")
|
||||
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_pricelist_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_pricelist_tour.ready", login="admin")
|
||||
|
||||
def test_06_product_configurator_optional_products(self):
|
||||
"""The goal of this test is to check that the product configurator
|
||||
window opens correctly and lets you select optional products even
|
||||
if the main product does not have variants.
|
||||
"""
|
||||
|
||||
# add an optional product to the office chair for test purposes
|
||||
office_chair = self.env.ref('product.product_product_12')
|
||||
office_chair.update({
|
||||
'optional_product_ids': [(6, 0, [self.env.ref('sale_product_configurator.product_product_1_product_template').id])]
|
||||
})
|
||||
|
||||
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_optional_products_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_optional_products_tour.ready", login="admin")
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<script type="text/javascript" src="/sale_product_configurator/static/tests/product_configurator_edition_test_ui.js"/>
|
||||
<script type="text/javascript" src="/sale_product_configurator/static/tests/product_configurator_single_custom_attribute_test_ui.js"/>
|
||||
<script type="text/javascript" src="/sale_product_configurator/static/tests/product_configurator_pricelist_test_ui.js"/>
|
||||
<script type="text/javascript" src="/sale_product_configurator/static/tests/product_configurator_optional_products_test_ui.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
<template id="qunit_suite" inherit_id="web.qunit_suite" name="sale_product_configurator_tests">
|
||||
|
||||
@@ -14,4 +14,45 @@
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_order_view_form" model="ir.ui.view">
|
||||
<field name="name">sale.order.form.inherit.sale.product.configurator</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree/field[@name='product_id']" position="after">
|
||||
<field
|
||||
name="product_template_id"
|
||||
string="Product"
|
||||
options="{'no_open': True}"
|
||||
attrs="{
|
||||
'readonly': [('product_updatable', '=', False)],
|
||||
'required': [('display_type', '=', False)],
|
||||
}"
|
||||
context="{
|
||||
'partner_id': parent.partner_id,
|
||||
'quantity': product_uom_qty,
|
||||
'pricelist': parent.pricelist_id,
|
||||
'uom':product_uom,
|
||||
'company_id': parent.company_id,
|
||||
'default_lst_price': price_unit,
|
||||
'default_description_sale': name
|
||||
}"
|
||||
widget="product_configurator"
|
||||
/>
|
||||
<field name="product_template_attribute_value_ids" invisible="1" />
|
||||
<field name="product_custom_attribute_value_ids" invisible="1" >
|
||||
<tree>
|
||||
<field name="attribute_value_id" />
|
||||
<field name="custom_value" />
|
||||
</tree>
|
||||
</field>
|
||||
<field name="product_no_variant_attribute_value_ids" invisible="1" />
|
||||
<field name="is_configurable_product" invisible="1" />
|
||||
</xpath>
|
||||
<xpath expr="//tree/field[@name='product_id']" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -10,55 +10,59 @@
|
||||
<template id="configure" name="Configure">
|
||||
<div class="js_product main_product">
|
||||
|
||||
<t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/>
|
||||
<t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
|
||||
<t t-set="combination" t-value="product_combination if product_combination else product._get_first_possible_combination()"/>
|
||||
<t t-set="combination_info" t-value="product._get_combination_info(combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
|
||||
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
|
||||
|
||||
<input type="hidden" class="product_template_id" t-att-value="product.id"/>
|
||||
<input type="hidden" class="product_id" t-attf-name="product_id" t-att-value="product_variant.id"/>
|
||||
<div class="col-lg-12 text-center mt-5">
|
||||
<div class="col-lg-5 d-inline-block align-top text-left">
|
||||
<img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image' % product_variant.id" class="d-block product_detail_img" alt="Product Image"/>
|
||||
<img t-else="" t-att-src="'/web/image/product.template/%s/image' % product.id" class="d-block product_detail_img" alt="Product Image"/>
|
||||
</div>
|
||||
<div class="col-lg-1 d-inline-block"></div>
|
||||
<div t-if="product._is_add_to_cart_possible()" class="col-lg-5 d-inline-block text-left">
|
||||
<t t-if="first_possible_combination" t-call="sale.variants">
|
||||
<t t-set="parent_combination" t-value="None"/>
|
||||
</t>
|
||||
<h2>
|
||||
<span t-attf-class="text-danger oe_default_price oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
|
||||
t-esc="combination_info['list_price']"
|
||||
t-options='{
|
||||
"widget": "monetary",
|
||||
"display_currency": (pricelist or product).currency_id
|
||||
}'/>
|
||||
<span class="oe_price product_id mt-3" style="white-space: nowrap;"
|
||||
t-att-data-product-id="product.id"
|
||||
t-esc="combination_info['price']"
|
||||
t-options='{
|
||||
"widget": "monetary",
|
||||
"display_currency": (pricelist or product).currency_id
|
||||
}'/>
|
||||
</h2>
|
||||
<div class="css_quantity input-group oe_website_spinner">
|
||||
<div class="input-group-prepend">
|
||||
<button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
|
||||
<i class="fa fa-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" t-att-value="add_qty or 1"/>
|
||||
<div class="input-group-append">
|
||||
<button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one">
|
||||
<i class="fa fa-plus"></i>
|
||||
</button>
|
||||
<t t-if="product._is_add_to_cart_possible()">
|
||||
<div class="col-lg-5 d-inline-block text-left">
|
||||
<t t-if="combination" t-call="sale.variants">
|
||||
<t t-set="parent_combination" t-value="None"/>
|
||||
</t>
|
||||
<h2>
|
||||
<span t-attf-class="text-danger oe_default_price oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
|
||||
t-esc="combination_info['list_price']"
|
||||
t-options='{
|
||||
"widget": "monetary",
|
||||
"display_currency": (pricelist or product).currency_id
|
||||
}'/>
|
||||
<span class="oe_price product_id mt-3" style="white-space: nowrap;"
|
||||
t-att-data-product-id="product.id"
|
||||
t-esc="combination_info['price']"
|
||||
t-options='{
|
||||
"widget": "monetary",
|
||||
"display_currency": (pricelist or product).currency_id
|
||||
}'/>
|
||||
</h2>
|
||||
<div class="css_quantity input-group oe_website_spinner">
|
||||
<div class="input-group-prepend">
|
||||
<button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
|
||||
<i class="fa fa-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" t-att-value="add_qty or 1"/>
|
||||
<div class="input-group-append">
|
||||
<button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one">
|
||||
<i class="fa fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="css_not_available_msg alert alert-warning">This combination does not exist.</p>
|
||||
</div>
|
||||
<p class="css_not_available_msg alert alert-warning">This combination does not exist.</p>
|
||||
</div>
|
||||
<div t-else="" class="col-lg-5 d-inline-block text-left">
|
||||
<p class="alert alert-warning">This product has no valid combination.</p>
|
||||
</div>
|
||||
<div class="col-lg-1 d-inline-block"></div>
|
||||
<div class="col-lg-5 d-inline-block align-top text-left">
|
||||
<img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image' % product_variant.id" class="d-block product_detail_img" alt="Product Image"/>
|
||||
<img t-else="" t-att-src="'/web/image/product.template/%s/image' % product.id" class="d-block product_detail_img" alt="Product Image"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="col-lg-5 d-inline-block text-left">
|
||||
<p class="alert alert-warning">This product has no valid combination.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -151,10 +155,10 @@
|
||||
<!-- modal: optional products -->
|
||||
<template id="optional_product_items">
|
||||
<t t-foreach="product.optional_product_ids" t-as="product">
|
||||
<t t-set="first_possible_combination" t-value="product._get_first_possible_combination(parent_combination)"/>
|
||||
<t t-set="combination" t-value="product._get_first_possible_combination(parent_combination)"/>
|
||||
<t t-if="product._is_add_to_cart_possible(parent_combination)">
|
||||
|
||||
<t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
|
||||
<t t-set="combination_info" t-value="product._get_combination_info(combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
|
||||
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
|
||||
|
||||
<tr class="js_product">
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import sale_product_configurator
|
||||
@@ -10,5 +10,12 @@ class SaleProductConfigurator(models.TransientModel):
|
||||
|
||||
product_template_id = fields.Many2one(
|
||||
'product.template', string="Product",
|
||||
required=True, domain=[('sale_ok', '=', True), '|', ('attribute_line_ids.value_ids', '!=', False), ('optional_product_ids', '!=', False)])
|
||||
required=True, domain=[('sale_ok', '=', True), ('attribute_line_ids.value_ids', '!=', False)])
|
||||
quantity = fields.Integer('Quantity')
|
||||
pricelist_id = fields.Many2one('product.pricelist', 'Pricelist', readonly=True)
|
||||
product_template_attribute_value_ids = fields.Many2many(
|
||||
'product.template.attribute.value', 'product_configurator_template_attribute_value_rel', string='Template Attribute Values', readonly=True)
|
||||
product_custom_attribute_value_ids = fields.Many2many(
|
||||
'product.attribute.custom.value', 'product_configurator_custom_attribute_value_rel', string='User entered custom product attribute values')
|
||||
product_no_variant_attribute_value_ids = fields.Many2many(
|
||||
'product.template.attribute.value', 'product_configurator_no_variant_attribute_value_rel', string='Product attribute values that do not create variants')
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="sale_product_configurator_view_form" model="ir.ui.view">
|
||||
<field name="name">sale.product.configurator.view.form</field>
|
||||
<field name="name">sale_product_configurator.product.configurator.view.form</field>
|
||||
<field name="model">sale.product.configurator</field>
|
||||
<field name="arch" type="xml">
|
||||
<form js_class="product_configurator_form">
|
||||
<group>
|
||||
<field name="product_template_id" class="oe_product_configurator_product_template_id" />
|
||||
<field name="product_template_attribute_value_ids" invisible="1" />
|
||||
<field name="product_custom_attribute_value_ids" invisible="1" widget="one2many" >
|
||||
<tree>
|
||||
<field name="attribute_value_id"/>
|
||||
<field name="custom_value"/>
|
||||
</tree>
|
||||
</field>
|
||||
<field name="product_no_variant_attribute_value_ids" invisible="1" />
|
||||
<field name="quantity" invisible="1" />
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Add" class="btn-primary o_sale_product_configurator_add disabled"/>
|
||||
<button string="Add" class="btn-primary o_sale_product_configurator_add"/>
|
||||
<button string="Save" class="btn-primary o_sale_product_configurator_edit" style="display: none;" />
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_product_configurator_action" model="ir.actions.act_window">
|
||||
<field name="name">Configure a product</field>
|
||||
<field name="res_model">sale.product.configurator</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="view_id" ref="sale_product_configurator_view_form"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -1347,7 +1347,7 @@ var BasicModel = AbstractModel.extend({
|
||||
for (var fieldName in changes) {
|
||||
field = record.fields[fieldName];
|
||||
if (field.type === 'one2many' || field.type === 'many2many') {
|
||||
defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options.viewType, options.allowWarning));
|
||||
defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options));
|
||||
} else if (field.type === 'many2one' || field.type === 'reference') {
|
||||
defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName]));
|
||||
} else {
|
||||
@@ -1677,14 +1677,15 @@ var BasicModel = AbstractModel.extend({
|
||||
* @param {string} fieldName
|
||||
* @param {Object} command A command object. It should have a 'operation'
|
||||
* key. For example, it looks like {operation: ADD, id: 'partner_1'}
|
||||
* @param {string} [viewType] current viewType. If not set, we will assume
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.viewType] current viewType. If not set, we will assume
|
||||
* main viewType from the record
|
||||
* @param {boolean} [allowWarning=false] if true, change
|
||||
* @param {boolean} [options.allowWarning=false] if true, change
|
||||
* operation can complete, even if a warning is raised
|
||||
* (only supported by the 'CREATE' command.operation)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_applyX2ManyChange: function (record, fieldName, command, viewType, allowWarning) {
|
||||
_applyX2ManyChange: function (record, fieldName, command, options) {
|
||||
if (command.operation === 'TRIGGER_ONCHANGE') {
|
||||
// the purpose of this operation is to trigger an onchange RPC, so
|
||||
// there is no need to apply any change on the record (the changes
|
||||
@@ -1697,7 +1698,8 @@ var BasicModel = AbstractModel.extend({
|
||||
var localID = (record._changes && record._changes[fieldName]) || record.data[fieldName];
|
||||
var list = this.localData[localID];
|
||||
var field = record.fields[fieldName];
|
||||
var fieldInfo = record.fieldsInfo[viewType || record.viewType][fieldName];
|
||||
var viewType = (options && options.viewType) || record.viewType;
|
||||
var fieldInfo = record.fieldsInfo[viewType][fieldName];
|
||||
var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
|
||||
var def, rec;
|
||||
var defs = [];
|
||||
@@ -1776,12 +1778,12 @@ var BasicModel = AbstractModel.extend({
|
||||
}
|
||||
break;
|
||||
case 'CREATE':
|
||||
var options = {
|
||||
var createOptions = _.extend({
|
||||
context: command.context,
|
||||
position: command.position,
|
||||
allowWarning: allowWarning
|
||||
};
|
||||
def = this._addX2ManyDefaultRecord(list, options).then(function (ids) {
|
||||
position: command.position
|
||||
}, options || {});
|
||||
|
||||
def = this._addX2ManyDefaultRecord(list, createOptions).then(function (ids) {
|
||||
_.each(ids, function(id){
|
||||
if (command.position === 'bottom' && list.orderedResIDs && list.orderedResIDs.length >= list.limit) {
|
||||
list.tempLimitIncrement = (list.tempLimitIncrement || 0) + 1;
|
||||
@@ -1822,6 +1824,17 @@ var BasicModel = AbstractModel.extend({
|
||||
list._changes.push({operation: operation, id: id});
|
||||
});
|
||||
break;
|
||||
case 'DELETE_ALL':
|
||||
// first remove all pending 'ADD' operations
|
||||
list._changes = _.reject(list._changes, function (change) {
|
||||
return change.operation === 'ADD';
|
||||
});
|
||||
|
||||
// then apply 'DELETE' on existing records
|
||||
return this._applyX2ManyChange(record, fieldName, {
|
||||
operation: 'DELETE',
|
||||
ids: list.res_ids
|
||||
}, options);
|
||||
case 'REPLACE_WITH':
|
||||
// this is certainly not optimal... and not sure that it is
|
||||
// correct if some ids are added and some other are removed
|
||||
@@ -1836,7 +1849,7 @@ var BasicModel = AbstractModel.extend({
|
||||
addDef = this._applyX2ManyChange(record, fieldName, {
|
||||
operation: 'ADD_M2M',
|
||||
ids: values
|
||||
}, viewType);
|
||||
}, options);
|
||||
}
|
||||
if (removedIds.length) {
|
||||
var listData = _.map(list.data, function (localId) {
|
||||
@@ -1850,9 +1863,20 @@ var BasicModel = AbstractModel.extend({
|
||||
}
|
||||
return _.findWhere(listData, {res_id: resID}).id;
|
||||
}),
|
||||
}, viewType);
|
||||
}, options);
|
||||
}
|
||||
return Promise.all([addDef, removedDef]);
|
||||
case 'MULTI':
|
||||
// allows batching multiple operations
|
||||
_.each(command.commands, function (innerCommand){
|
||||
defs.push(self._applyX2ManyChange(
|
||||
record,
|
||||
fieldName,
|
||||
innerCommand,
|
||||
options
|
||||
));
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return Promise.all(defs).then(function () {
|
||||
@@ -1911,7 +1935,10 @@ var BasicModel = AbstractModel.extend({
|
||||
case 'FORGET':
|
||||
case 'DELETE':
|
||||
list.count--;
|
||||
list.res_ids = _.without(list.res_ids, relRecord.res_id);
|
||||
// FIXME awa: there is no "relRecord" for o2m field
|
||||
// seems like using change.id does the trick -> check with framework JS
|
||||
var deletedResID = relRecord ? relRecord.res_id : change.id;
|
||||
list.res_ids = _.without(list.res_ids, deletedResID);
|
||||
break;
|
||||
case 'REMOVE_ALL':
|
||||
list.count = 0;
|
||||
|
||||
@@ -2155,5 +2155,166 @@ odoo.define('web.basic_model_tests', function (require) {
|
||||
assert.strictEqual(record.data.foobool2, true, "foobool2 field should be true");
|
||||
model.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('notifyChange DELETE_ALL on a one2many', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
this.data.partner.records[1].product_ids = [37, 38];
|
||||
this.params.fieldNames = ['product_ids'];
|
||||
|
||||
var model = createModel({
|
||||
Model: BasicModel,
|
||||
data: this.data,
|
||||
});
|
||||
|
||||
var o2mParams = {
|
||||
modelName: 'product',
|
||||
fields: this.data.product.fields,
|
||||
};
|
||||
|
||||
var resultID = await model.load(this.params);
|
||||
var newRecordID = await model.load(o2mParams);
|
||||
var record = model.get(resultID);
|
||||
var x2mListID = record.data.product_ids.id;
|
||||
|
||||
assert.strictEqual(record.data.product_ids.count, 2,
|
||||
"there should be two records in the relation");
|
||||
|
||||
await model.notifyChanges(resultID, {product_ids: {operation: 'ADD', id: newRecordID}});
|
||||
|
||||
assert.deepEqual(model.localData[x2mListID]._changes, [{
|
||||
operation: 'ADD', id: newRecordID,
|
||||
}], "_changes should be correct");
|
||||
|
||||
record = model.get(resultID);
|
||||
assert.strictEqual(record.data.product_ids.count, 3,
|
||||
"there should be three records in the relation");
|
||||
|
||||
await model.notifyChanges(resultID, {product_ids: {operation: 'DELETE_ALL'}});
|
||||
|
||||
assert.deepEqual(model.localData[x2mListID]._changes, [{
|
||||
id: 37,
|
||||
operation: "DELETE"
|
||||
}, {
|
||||
id: 38,
|
||||
operation: "DELETE"
|
||||
}], "_changes should contain the two 'DELETE' operations");
|
||||
|
||||
record = model.get(resultID);
|
||||
assert.strictEqual(record.data.product_ids.count, 0,
|
||||
"there should be no more records in the relation");
|
||||
model.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('notifyChange MULTI on a one2many', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
this.data.partner.records[1].product_ids = [37, 38];
|
||||
this.params.fieldNames = ['product_ids'];
|
||||
|
||||
var model = createModel({
|
||||
Model: BasicModel,
|
||||
data: this.data,
|
||||
});
|
||||
|
||||
var o2mParams = {
|
||||
modelName: 'product',
|
||||
fields: this.data.product.fields,
|
||||
};
|
||||
|
||||
var resultID = await model.load(this.params);
|
||||
var newRecordID = await model.load(o2mParams);
|
||||
var record = model.get(resultID);
|
||||
var x2mListID = record.data.product_ids.id;
|
||||
|
||||
assert.strictEqual(record.data.product_ids.count, 2,
|
||||
"there should be two records in the relation");
|
||||
|
||||
await model.notifyChanges(resultID, {product_ids: {
|
||||
operation: 'MULTI',
|
||||
commands: [{
|
||||
operation: 'DELETE_ALL'
|
||||
}, {
|
||||
operation: 'ADD',
|
||||
id: newRecordID
|
||||
}]
|
||||
}});
|
||||
|
||||
assert.deepEqual(model.localData[x2mListID]._changes, [{
|
||||
id: 37,
|
||||
operation: "DELETE"
|
||||
}, {
|
||||
id: 38,
|
||||
operation: "DELETE"
|
||||
}, {
|
||||
operation: 'ADD', id: newRecordID,
|
||||
}], "_changes should be correct");
|
||||
|
||||
record = model.get(resultID);
|
||||
assert.strictEqual(record.data.product_ids.count, 1,
|
||||
"there should be one record in the relation");
|
||||
|
||||
assert.strictEqual(record.data.product_ids.data[0].id, newRecordID,
|
||||
"the id should match");
|
||||
});
|
||||
|
||||
QUnit.test('notifyChange MULTI on a many2many', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
this.params.fieldsInfo = {
|
||||
default: {
|
||||
category: {
|
||||
fieldsInfo: {default: {some_char: { context: "{'a': parent.foo}"}}},
|
||||
relatedFields: {some_char: {type: "char"}},
|
||||
viewType: 'default',
|
||||
},
|
||||
foo: {},
|
||||
},
|
||||
};
|
||||
|
||||
var model = createModel({
|
||||
Model: BasicModel,
|
||||
data: this.data,
|
||||
});
|
||||
|
||||
var resultID = await model.load(this.params);
|
||||
var changes = {
|
||||
category: {
|
||||
operation: 'MULTI',
|
||||
commands: [{
|
||||
operation: 'ADD_M2M',
|
||||
ids: [{id: 23}, {id: 24}, {id: 25}]
|
||||
}, {
|
||||
operation: 'ADD_M2M',
|
||||
ids: [{id: 26}]
|
||||
}]
|
||||
}
|
||||
};
|
||||
await model.notifyChanges(resultID, changes);
|
||||
var record = model.get(resultID);
|
||||
var categoryRecord = record.data.category;
|
||||
|
||||
assert.strictEqual(categoryRecord.data.length, 4,
|
||||
"there should 2 records in the relation");
|
||||
|
||||
await model.notifyChanges(resultID, {category: {
|
||||
operation: 'MULTI',
|
||||
commands: [{
|
||||
operation: 'DELETE_ALL'
|
||||
}, {
|
||||
operation: 'ADD_M2M',
|
||||
ids: [{id: 27}]
|
||||
}]
|
||||
}});
|
||||
record = model.get(resultID);
|
||||
categoryRecord = record.data.category;
|
||||
assert.strictEqual(categoryRecord.data.length, 1,
|
||||
"there should 1 record in the relation");
|
||||
|
||||
assert.strictEqual(record.data.category.data[0].data.id, 27,
|
||||
"the id should match");
|
||||
|
||||
model.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -369,7 +369,7 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(ProductConfigurat
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_updateProductImage: function ($productContainer, productId, productTemplateId, new_carousel) {
|
||||
_updateProductImage: function ($productContainer, displayImage, productId, productTemplateId, new_carousel) {
|
||||
var $img;
|
||||
var $carousel = $productContainer.find('#o-carousel-product');
|
||||
|
||||
@@ -415,7 +415,8 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(ProductConfigurat
|
||||
}
|
||||
}
|
||||
|
||||
$carousel.toggleClass('css_not_available', !this.isSelectedVariantAllowed);
|
||||
$carousel.toggleClass('css_not_available',
|
||||
$productContainer.find('.js_main_product').hasClass('css_not_available'));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="group_product_variant"/>
|
||||
<div class="text-muted">
|
||||
Set product attributes (e.g. color, size) to sell variants
|
||||
Sell variants of a product using attributes (size, color, etc.)
|
||||
</div>
|
||||
<div class="content-group" attrs="{'invisible': [('group_product_variant', '=', False)]}">
|
||||
<div class="mt16">
|
||||
@@ -27,12 +27,12 @@
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="module_sale_product_configurator"/>
|
||||
<field name="module_sale_product_configurator" string="Optional Products"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="module_sale_product_configurator" string="Optional Products"/>
|
||||
<div class="text-muted">
|
||||
Configure your attributes with exclusions and select optional products
|
||||
Display a prompt with optional products when adding to cart
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -418,8 +418,8 @@
|
||||
|
||||
<template id="product" name="Product">
|
||||
|
||||
<t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/>
|
||||
<t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
|
||||
<t t-set="combination" t-value="product._get_first_possible_combination()"/>
|
||||
<t t-set="combination_info" t-value="product._get_combination_info(combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
|
||||
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
|
||||
|
||||
<t t-call="website.layout">
|
||||
@@ -462,7 +462,7 @@
|
||||
<t t-placeholder="select">
|
||||
<input type="hidden" class="product_id" name="product_id" t-att-value="product_variant.id" />
|
||||
<input type="hidden" class="product_template_id" name="product_template_id" t-att-value="product.id" />
|
||||
<t t-if="first_possible_combination" t-call="sale.variants">
|
||||
<t t-if="combination" t-call="sale.variants">
|
||||
<t t-set="ul_class" t-value="'flex-column'" />
|
||||
<t t-set="parent_combination" t-value="None" />
|
||||
</t>
|
||||
|
||||
+7
-1
@@ -25,11 +25,17 @@ OptionalProductsModal.include({
|
||||
this.isWebsite = params.isWebsite;
|
||||
|
||||
if (this.isWebsite) {
|
||||
delete this.events['change [data-attribute_exclusions]'];
|
||||
delete this.events['click button.js_add_cart_json'];
|
||||
}
|
||||
|
||||
this.dialogClass = 'oe_optional_products_modal' + (params.isWebsite ? ' oe_website_sale' : '');
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_triggerPriceUpdateOnChangeQuantity: function () {
|
||||
return !this.isWebsite;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -325,44 +325,69 @@ tour.register('main_flow_tour', {
|
||||
content: _t("Click here to add some lines to your quotations."),
|
||||
position: "bottom",
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=product_id] input",
|
||||
/**
|
||||
* We need both triggers because the "sale_product_configurator" module replaces the
|
||||
* "product_id" field with a "product_template_id" field.
|
||||
* This selector will still only ever select one element.
|
||||
*/
|
||||
trigger: ".o_field_widget[name=product_id] input, .o_field_widget[name=product_template_id] input",
|
||||
content: _t("Select a product, or create a new one on the fly. The product will define the default sales price (that you can change), taxes and description automatically."),
|
||||
position: "right",
|
||||
run: "text the_flow.product",
|
||||
run: function (actions) {
|
||||
actions.text("the_flow.product", this.$anchor);
|
||||
// fake keydown to trigger search
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
this.$anchor.trigger(keyDownEvent);
|
||||
var $descriptionElement = $('.o_form_editable textarea[name="name"]');
|
||||
// when description changes, we know the product has been loaded
|
||||
$descriptionElement.change(function () {
|
||||
if ($(this).val().indexOf('the_flow.product') !== -1){
|
||||
$(this).addClass('product_loading_success');
|
||||
}
|
||||
});
|
||||
},
|
||||
}, {
|
||||
trigger: ".ui-menu-item > a:contains('the_flow.product')",
|
||||
auto: true,
|
||||
in_modal: false,
|
||||
run: function (actions) {
|
||||
actions.auto();
|
||||
// if the one2many isn't editable, we have to close the dialog
|
||||
if ($(".modal-footer .btn-primary").length) {
|
||||
actions.auto(".modal-footer .btn-primary");
|
||||
}
|
||||
},
|
||||
}, {
|
||||
trigger: '.o_form_editable textarea[name="name"].product_loading_success',
|
||||
run: function () {} // wait for product loading
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=order_line] .o_field_x2many_list_row_add > a",
|
||||
content: _t("Click here to add some lines to your quotations."),
|
||||
position: "bottom",
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=product_id] input",
|
||||
// the one2many may be editable or not according to the modules installed, so
|
||||
// we have to handle both cases
|
||||
extra_trigger: '.o_field_widget[name=order_line] .o_data_row:nth(1).o_selected_row, .modal-dialog',
|
||||
/**
|
||||
* We need both triggers because the "sale_product_configurator" module replaces the
|
||||
* "product_id" field with a "product_template_id" field.
|
||||
* This selector will still only ever select one element.
|
||||
*/
|
||||
trigger: ".o_field_widget[name=product_id] input, .o_field_widget[name=product_template_id] input",
|
||||
extra_trigger: '.o_field_widget[name=order_line] .o_data_row:nth(1).o_selected_row',
|
||||
content: _t("Select a product"),
|
||||
position: "right",
|
||||
run: "text the_flow.service",
|
||||
run: function (actions) {
|
||||
actions.text("the_flow.service", this.$anchor);
|
||||
// fake keydown to trigger search
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
this.$anchor.trigger(keyDownEvent);
|
||||
var $descriptionElement = $('.o_form_editable textarea[name="name"]');
|
||||
// when description changes, we know the product has been loaded
|
||||
$descriptionElement.change(function () {
|
||||
if ($(this).val().indexOf('the_flow.service') !== -1){
|
||||
$(this).addClass('product_service_loading_success');
|
||||
}
|
||||
});
|
||||
},
|
||||
}, {
|
||||
trigger: ".ui-menu-item > a:contains('the_flow.service')",
|
||||
auto: true,
|
||||
in_modal: false,
|
||||
run: function (actions) {
|
||||
actions.auto();
|
||||
// if the one2many isn't editable, we have to close the dialog
|
||||
if ($(".modal-footer .btn-primary").length) {
|
||||
actions.auto(".modal-footer .btn-primary");
|
||||
}
|
||||
},
|
||||
}, {
|
||||
trigger: '.o_form_editable textarea[name="name"].product_service_loading_success',
|
||||
run: function () {} // wait for product loading
|
||||
}, {
|
||||
trigger: 'label:contains("Untaxed Amount")',
|
||||
// click somewhere else to exit cell focus
|
||||
}, {
|
||||
trigger: ".o_statusbar_buttons > button.o_sale_print:enabled",
|
||||
content: _t("<p><b>Print this quotation.</b></p>"),
|
||||
|
||||
Reference in New Issue
Block a user