[FW][MERGE][REF] sale,purchase,*: migrate SO & PO form views to OWL
Adapt (part of) the `sale.order` & `purchase.order` custom javascript logic to the new OWL framework. 1) `product_configurator_widget` -> `sol_product_many2one` Impacted modules: sale, sale_product_configurator, sale_product_matrix, event_sale, event_booth_sale, sale_renting 2) `matrix_configurator` -> `pol_product_many2one` Impacted module: purchase_product_matrix 3) `product_discount` -> `sol_discount` Impacted module: sale 4) Custom js_class for SO form view is no longer necessary (and legacy js_class removed from the PO form view) Task-2918791 COM PR: #101304 ENT PR: odoo/enterprise#31854 closes odoo/odoo#101352 Forward-port-of: odoo/odoo#101304 Related: odoo/enterprise#31886 Signed-off-by: Victor Feyens (vfe) <vfe@odoo.com> Co-authored-by: Horacio Tellez Perez <hote@odoo.com> Co-authored-by: Morgane Demesmaeker <edm@odoo.com> Co-authored-by: Valentin Chevalier <vcr@odoo.com> Co-authored-by: Samuel Degueldre <sad@odoo.com>
This commit is contained in:
co-authored by
Horacio Tellez Perez
Morgane Demesmaeker
Valentin Chevalier
Samuel Degueldre
commit
8787031cd9
@@ -24,9 +24,7 @@ Sell your event booths and track payments on sale orders.
|
||||
'auto_install': True,
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'event_booth_sale/static/src/js/event_booth_configurator_form_controller.js',
|
||||
'event_booth_sale/static/src/js/event_booth_configurator_form_view.js',
|
||||
'event_booth_sale/static/src/js/event_booth_configurator_widget.js',
|
||||
'event_booth_sale/static/src/**/*',
|
||||
]
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
odoo.define('event_booth_sale.event_booth_configurator_form_controller', function (require) {
|
||||
'use strict';
|
||||
|
||||
const FormController = require('web.FormController');
|
||||
/**
|
||||
* This controller is overridden to allow configuring sale_order_lines through a popup
|
||||
* window when a product with 'detailed_type' == 'event_booth' is selected.
|
||||
*
|
||||
* This allows keeping an editable list view for sales order and remove the noise of
|
||||
* those 3 fields ('event_id', 'event_booth_category_id' and 'event_booth_ids')
|
||||
*/
|
||||
const EventBoothConfiguratorFormController = FormController.extend({
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Overrides
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
saveRecord: function () {
|
||||
const self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
const state = self.model.get(self.handle, {raw: true});
|
||||
self.do_action({
|
||||
type: 'ir.actions.act_window_close',
|
||||
infos: {
|
||||
eventBoothConfiguration: {
|
||||
event_id: {id: state.data.event_id},
|
||||
event_booth_category_id: {id: state.data.event_booth_category_id},
|
||||
event_booth_pending_ids: {
|
||||
operation: 'MULTI',
|
||||
commands: [{
|
||||
operation: 'REPLACE_WITH',
|
||||
ids: state.data.event_booth_ids,
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return EventBoothConfiguratorFormController;
|
||||
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
odoo.define('event_booth_sale.event_booth_configurator_form_view', function (require) {
|
||||
'use strict';
|
||||
|
||||
const EventBoothConfiguratorFormController = require('event_booth_sale.event_booth_configurator_form_controller');
|
||||
const FormView = require('web.FormView');
|
||||
const viewRegistry = require('web.view_registry');
|
||||
|
||||
/**
|
||||
* @see EventBoothConfiguratorFormController for more information.
|
||||
*/
|
||||
const EventBoothConfiguratorFormView = FormView.extend({
|
||||
config: _.extend({}, FormView.prototype.config, {
|
||||
Controller: EventBoothConfiguratorFormController,
|
||||
}),
|
||||
});
|
||||
|
||||
viewRegistry.add('event_booth_configurator_form', EventBoothConfiguratorFormView);
|
||||
|
||||
return EventBoothConfiguratorFormView;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { formView } from "@web/views/form/form_view"
|
||||
import { Record, RelationalModel } from "@web/views/relational_model";
|
||||
|
||||
/**
|
||||
* This model is overridden to allow configuring sale_order_lines through a popup
|
||||
* window when a product with 'detailed_type' == 'event_booth' is selected.
|
||||
*
|
||||
* This allows keeping an editable list view for sales order and remove the noise of
|
||||
* those 3 fields ('event_id', 'event_booth_category_id' and 'event_booth_ids')
|
||||
*/
|
||||
class EventBoothConfiguratorRelationalModel extends RelationalModel {}
|
||||
|
||||
class EventBoothConfiguratorRecord extends Record {
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Overrides
|
||||
//--------------------------------------------------------------------------
|
||||
async save(options = {}) {
|
||||
await super.save(options);
|
||||
this.model.action.doAction({type: 'ir.actions.act_window_close', infos: {
|
||||
eventBoothConfiguration: {
|
||||
event_id: this.data.event_id,
|
||||
event_booth_category_id: this.data.event_booth_category_id,
|
||||
event_booth_pending_ids: {
|
||||
operation: 'MULTI',
|
||||
commands: [{
|
||||
operation: 'REPLACE_WITH',
|
||||
ids: this.data.event_booth_ids.currentIds,
|
||||
}],
|
||||
}
|
||||
}
|
||||
}});
|
||||
}
|
||||
}
|
||||
|
||||
EventBoothConfiguratorRelationalModel.Record = EventBoothConfiguratorRecord;
|
||||
|
||||
registry.category("views").add("event_booth_configurator_form", {
|
||||
...formView,
|
||||
Model: EventBoothConfiguratorRelationalModel,
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
odoo.define('event_booth_sale.product_configurator', function (require) {
|
||||
const ProductConfiguratorWidget = require('sale.product_configurator');
|
||||
|
||||
/**
|
||||
* Extension of the ProductConfiguratorWidget to support event booth configuration.
|
||||
* It opens when an event booth product_product is set.
|
||||
*
|
||||
* The event booth information include:
|
||||
* - event_id
|
||||
* - event_booth_category_id
|
||||
* - event_booth_ids
|
||||
*
|
||||
*/
|
||||
ProductConfiguratorWidget.include({
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Overrides
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_isConfigurableLine: function () {
|
||||
return this.recordData.detailed_type === 'event_booth' || this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the event booth configurator in 'edit' mode
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onEditLineConfiguration: function () {
|
||||
if (this.recordData.detailed_type === 'event_booth') {
|
||||
const defaultValues = {
|
||||
default_product_id: this.recordData.product_id.data.id,
|
||||
default_sale_order_line_id: this.recordData.id || null
|
||||
};
|
||||
if (this.recordData.event_id) {
|
||||
defaultValues.default_event_id = this.recordData.event_id.data.id;
|
||||
}
|
||||
if (this.recordData.event_booth_category_id) {
|
||||
defaultValues.default_event_booth_category_id = this.recordData.event_booth_category_id.data.id;
|
||||
}
|
||||
if (this.recordData.event_booth_pending_ids) {
|
||||
defaultValues.default_event_booth_ids = this.recordData.event_booth_pending_ids.res_ids;
|
||||
}
|
||||
this._openEventBoothConfigurator(defaultValues, this.dataPointID);
|
||||
} else {
|
||||
this._super.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {integer} productId: product.product ID
|
||||
* @param {String} dataPointId
|
||||
* @returns {Promise<Boolean>} stopPropagation true if a suitable configurator has been found.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onProductChange: function (productId, dataPointId) {
|
||||
return this._super.apply(this, arguments).then((stopPropagation) => {
|
||||
if (stopPropagation) {
|
||||
return Promise.resolve(true);
|
||||
} else {
|
||||
return this._isProductEventBooth(productId, dataPointId);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* This method will check if the productId needs a configuration or not.
|
||||
*
|
||||
* @param {integer} productId: product.product ID
|
||||
* @param {String} dataPointId
|
||||
* @returns {Promise<Boolean>} stopPropagation true if the product is an event booth.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_isProductEventBooth: function (productId, dataPointId) {
|
||||
const self = this;
|
||||
return this._rpc({
|
||||
model: 'product.product',
|
||||
method: 'read',
|
||||
args: [productId, ['detailed_type']],
|
||||
}).then(function (result) {
|
||||
if (result && result[0].detailed_type === 'event_booth') {
|
||||
self._openEventBoothConfigurator({
|
||||
default_product_id: productId
|
||||
}, dataPointId);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the event booth configurator allowing to configure the SO line with its informations.
|
||||
*
|
||||
* 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', 'event_booth_category_id'
|
||||
* and 'event_booth_pending_ids', the product_id field is cleaned.
|
||||
*
|
||||
* @param {Object} additionalContext: various "default_" values added to context when calling
|
||||
* configuration wizard model;
|
||||
* @param dataPointId
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_openEventBoothConfigurator: function (additionalContext, dataPointId) {
|
||||
const self = this;
|
||||
this.do_action('event_booth_sale.event_booth_configurator_action', {
|
||||
additional_context: additionalContext,
|
||||
on_close: function (result) {
|
||||
if (result && !result.special) {
|
||||
self.trigger_up('field_changed', {
|
||||
changes: result.eventBoothConfiguration,
|
||||
dataPointID: dataPointId,
|
||||
onSuccess: function () {
|
||||
self._onLineConfigured();
|
||||
}
|
||||
});
|
||||
} else if (!self.recordData.event_id || !self.recordData.event_booth_pending_ids) {
|
||||
self.trigger_up('field_changed', {
|
||||
changes: {
|
||||
product_id: false,
|
||||
name: '',
|
||||
},
|
||||
dataPointID: dataPointId,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return ProductConfiguratorWidget;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { SaleOrderLineProductField } from '@sale/js/sale_product_field';
|
||||
|
||||
|
||||
patch(SaleOrderLineProductField.prototype, 'event_booth_sale', {
|
||||
|
||||
async _onProductUpdate() {
|
||||
this._super(...arguments);
|
||||
if (this.props.record.data.product_type === 'event_booth') {
|
||||
this._openEventBoothConfigurator(false);
|
||||
}
|
||||
},
|
||||
|
||||
_editLineConfiguration() {
|
||||
this._super(...arguments);
|
||||
if (this.props.record.data.product_type === 'event_booth') {
|
||||
this._openEventBoothConfigurator(true);
|
||||
}
|
||||
},
|
||||
|
||||
get isConfigurableLine() {
|
||||
return this._super(...arguments) || Boolean(this.props.record.data.event_booth_category_id);
|
||||
},
|
||||
|
||||
async _openEventBoothConfigurator(edit) {
|
||||
let actionContext = {
|
||||
default_product_id: this.props.record.data.product_id[0],
|
||||
};
|
||||
if (edit) {
|
||||
const recordData = this.props.record.data;
|
||||
if (recordData.event_id) {
|
||||
actionContext.default_event_id = recordData.event_id[0];
|
||||
}
|
||||
if (recordData.event_booth_category_id) {
|
||||
actionContext.default_event_booth_category_id = recordData.event_booth_category_id[0];
|
||||
}
|
||||
if (recordData.event_booth_pending_ids) {
|
||||
actionContext.default_event_booth_ids = recordData.event_booth_pending_ids.records.map(
|
||||
record => {
|
||||
return [4, record.data.id];
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
this.action.doAction(
|
||||
'event_booth_sale.event_booth_configurator_action',
|
||||
{
|
||||
additionalContext: actionContext,
|
||||
onClose: async (closeInfo) => {
|
||||
if (!closeInfo || closeInfo.special) {
|
||||
// wizard popup closed or 'Cancel' button triggered
|
||||
if (!this.props.record.data.event_ticket_id) {
|
||||
// remove product if event configuration was cancelled.
|
||||
this.props.record.update({
|
||||
[this.props.name]: undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const eventBoothConfiguration = closeInfo.eventBoothConfiguration;
|
||||
this.props.record.update({
|
||||
event_id: eventBoothConfiguration.event_id,
|
||||
event_booth_category_id: eventBoothConfiguration.event_booth_category_id,
|
||||
event_booth_pending_ids: eventBoothConfiguration.event_booth_pending_ids,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -48,9 +48,6 @@ this event.
|
||||
'web.assets_tests': [
|
||||
'event_sale/static/tests/tours/**/*',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'event_sale/static/tests/event_configurator.test.js',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -21,12 +21,14 @@ class SaleOrder(models.Model):
|
||||
def action_confirm(self):
|
||||
res = super(SaleOrder, self).action_confirm()
|
||||
for so in self:
|
||||
if not any(line.product_type == 'event' for line in so.order_line):
|
||||
continue
|
||||
# confirm registration if it was free (otherwise it will be confirmed once invoice fully paid)
|
||||
so.order_line._update_registrations(confirm=so.amount_total == 0, cancel_to_draft=False)
|
||||
if any(line.event_ok for line in so.order_line):
|
||||
return self.env['ir.actions.act_window'] \
|
||||
.with_context(default_sale_order_id=so.id) \
|
||||
._for_xml_id('event_sale.action_sale_order_event_registration')
|
||||
if len(self) == 1:
|
||||
return self.env['ir.actions.act_window'].with_context(
|
||||
default_sale_order_id=so.id
|
||||
)._for_xml_id('event_sale.action_sale_order_event_registration')
|
||||
return res
|
||||
|
||||
def _action_cancel(self):
|
||||
@@ -68,6 +70,7 @@ class SaleOrderLine(models.Model):
|
||||
'event.event.ticket', string='Event Ticket',
|
||||
compute="_compute_event_ticket_id", store=True, readonly=False, precompute=True,
|
||||
help="Choose an event ticket and it will automatically create a registration for this event ticket.")
|
||||
# TODO in master: remove this field, unused anymore
|
||||
event_ok = fields.Boolean(compute='_compute_event_ok')
|
||||
|
||||
@api.depends('product_id.detailed_type')
|
||||
@@ -89,7 +92,9 @@ class SaleOrderLine(models.Model):
|
||||
RegistrationSudo = self.env['event.registration'].sudo()
|
||||
registrations = RegistrationSudo.search([('sale_order_line_id', 'in', self.ids)])
|
||||
registrations_vals = []
|
||||
for so_line in self.filtered('event_ok'):
|
||||
for so_line in self:
|
||||
if not so_line.product_type == 'event':
|
||||
continue
|
||||
existing_registrations = registrations.filtered(lambda self: self.sale_order_line_id.id == so_line.id)
|
||||
if confirm:
|
||||
existing_registrations.filtered(lambda self: self.state not in ['open', 'cancel']).action_confirm()
|
||||
|
||||
@@ -28,8 +28,8 @@ class EventConfiguratorRecord extends Record {
|
||||
await super.save(options);
|
||||
this.model.action.doAction({type: 'ir.actions.act_window_close', infos: {
|
||||
eventConfiguration: {
|
||||
event_id: {id: this.data.event_id[0]},
|
||||
event_ticket_id: {id: this.data.event_ticket_id[0]}
|
||||
event_id: this.data.event_id,
|
||||
event_ticket_id: this.data.event_ticket_id,
|
||||
}
|
||||
}});
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
odoo.define('event_sale.product_configurator', function (require) {
|
||||
var ProductConfiguratorWidget = require('sale.product_configurator');
|
||||
|
||||
/**
|
||||
* Extension of the ProductConfiguratorWidget to support event product configuration.
|
||||
* It opens when an event product_product is set.
|
||||
*
|
||||
* The event information include:
|
||||
* - event_id
|
||||
* - event_ticket_id
|
||||
*
|
||||
*/
|
||||
ProductConfiguratorWidget.include({
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_isConfigurableLine: function () {
|
||||
return this.recordData.event_ok || this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {integer} productId
|
||||
* @param {String} dataPointID
|
||||
* @returns {Promise<Boolean>} stopPropagation true if a suitable configurator has been found.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onProductChange: function (productId, dataPointId) {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function (stopPropagation) {
|
||||
if (stopPropagation || productId === undefined) {
|
||||
return Promise.resolve(true);
|
||||
} else {
|
||||
return self._checkForEvent(productId, dataPointId);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* This method will check if the productId needs configuration or not:
|
||||
*
|
||||
* @param {integer} productId
|
||||
* @param {string} dataPointID
|
||||
* @returns {Promise<Boolean>} stopPropagation true if the product is an event ticket.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_checkForEvent: function (productId, dataPointId) {
|
||||
var self = this;
|
||||
return this._rpc({
|
||||
model: 'product.product',
|
||||
method: 'read',
|
||||
args: [productId, ['detailed_type']],
|
||||
}).then(function (result) {
|
||||
if (Array.isArray(result) && result.length && result[0].detailed_type === 'event') {
|
||||
self._openEventConfigurator({
|
||||
default_product_id: productId
|
||||
},
|
||||
dataPointId
|
||||
);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the event configurator in 'edit' mode.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onEditLineConfiguration: function () {
|
||||
if (this.recordData.event_ok) {
|
||||
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);
|
||||
} else {
|
||||
this._super.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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,
|
||||
onSuccess: function () {
|
||||
// Call post-init function.
|
||||
self._onLineConfigured();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (!self.recordData.event_id || !self.recordData.event_ticket_id) {
|
||||
self.trigger_up('field_changed', {
|
||||
dataPointID: dataPointId,
|
||||
changes: {
|
||||
product_id: false,
|
||||
name: ''
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return ProductConfiguratorWidget;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { SaleOrderLineProductField } from '@sale/js/sale_product_field';
|
||||
|
||||
|
||||
patch(SaleOrderLineProductField.prototype, 'event_sale', {
|
||||
|
||||
async _onProductUpdate() {
|
||||
this._super(...arguments);
|
||||
if (this.props.record.data.product_type === 'event') {
|
||||
this._openEventConfigurator();
|
||||
}
|
||||
},
|
||||
|
||||
_editLineConfiguration() {
|
||||
this._super(...arguments);
|
||||
if (this.props.record.data.product_type === 'event') {
|
||||
this._openEventConfigurator();
|
||||
}
|
||||
},
|
||||
|
||||
get isConfigurableLine() {
|
||||
return this._super(...arguments) || Boolean(this.props.record.data.event_ticket_id);
|
||||
},
|
||||
|
||||
async _openEventConfigurator() {
|
||||
let actionContext = {
|
||||
'default_product_id': this.props.record.data.product_id[0],
|
||||
};
|
||||
if (this.props.record.data.event_id) {
|
||||
actionContext.default_event_id = this.props.record.data.event_id[0];
|
||||
}
|
||||
if (this.props.record.data.event_ticket_id) {
|
||||
actionContext.default_event_ticket_id = this.props.record.data.event_ticket_id[0];
|
||||
}
|
||||
this.action.doAction(
|
||||
'event_sale.event_configurator_action',
|
||||
{
|
||||
additionalContext: actionContext,
|
||||
onClose: async (closeInfo) => {
|
||||
if (!closeInfo || closeInfo.special) {
|
||||
// wizard popup closed or 'Cancel' button triggered
|
||||
if (!this.props.record.data.event_ticket_id) {
|
||||
// remove product if event configuration was cancelled.
|
||||
this.props.record.update({
|
||||
[this.props.name]: undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const eventConfiguration = closeInfo.eventConfiguration;
|
||||
this.props.record.update({
|
||||
'event_id': eventConfiguration.event_id,
|
||||
'event_ticket_id': eventConfiguration.event_ticket_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
odoo.define('event.configurator.tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var FormView = require('web.FormView');
|
||||
var testUtils = require('web.test_utils');
|
||||
var createView = testUtils.createView;
|
||||
|
||||
var getArch = function (){
|
||||
return '<form>' +
|
||||
'<sheet>' +
|
||||
'<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" widget="product_configurator" />' +
|
||||
'</tree>' +
|
||||
'</field>' +
|
||||
'</sheet>' +
|
||||
'</form>';
|
||||
};
|
||||
|
||||
QUnit.module('Event Configurator', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
'product.product': {
|
||||
fields: {
|
||||
id: {type: 'integer'},
|
||||
detailed_type: {type: 'selection'},
|
||||
rent_ok: {type: 'boolean'}//sale_rental purposes
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
display_name: "Customizable Event",
|
||||
detailed_type: 'event',
|
||||
rent_ok: false//sale_rental purposes
|
||||
}, {
|
||||
id: 2,
|
||||
display_name: "Desk",
|
||||
detailed_type: 'service',
|
||||
rent_ok: false//sale_rental purposes
|
||||
}]
|
||||
},
|
||||
sale_order: {
|
||||
fields: {
|
||||
id: {type: 'integer'},
|
||||
sale_order_line: {
|
||||
string: 'lines',
|
||||
type: 'one2many',
|
||||
relation: 'sale_order_line'
|
||||
},
|
||||
}
|
||||
},
|
||||
sale_order_line: {
|
||||
fields: {
|
||||
product_id: {
|
||||
string: 'product',
|
||||
type: 'many2one',
|
||||
relation: 'product.product'
|
||||
},
|
||||
event_ok: {type: 'boolean'},
|
||||
rent_ok: {type: 'boolean'},//sale_rental purposes
|
||||
event_id: {
|
||||
string: 'event',
|
||||
type: 'many2one',
|
||||
relation: 'event'
|
||||
},
|
||||
event_ticket_id: {
|
||||
string: 'event_ticket',
|
||||
type: 'many2one',
|
||||
relation: 'event_ticket'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}, function (){
|
||||
QUnit.test('Select a regular product and verify that the event configurator is not opened', async function (assert) {
|
||||
// When the module event_booth_sale is installed, it will also call the rpc: read/detailed_type when selecting a product.
|
||||
// This means that 2 asserts are done when this module is installed.
|
||||
// Note that: this test will fail if the module event_booth_sale is not installed.
|
||||
assert.expect(2);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: getArch(),
|
||||
mockRPC: function (route, params) {
|
||||
if (params.method === 'read' && params.args[1][0] === 'detailed_type') {
|
||||
assert.ok(true);
|
||||
return Promise.resolve([{detailed_type: 'service'}]);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
intercepts: {
|
||||
do_action: function (ev) {
|
||||
if (ev.data.action === 'event_sale.event_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_id", {item: 'Desk'})
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Select a configurable event and verify that the event 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 === 'read' && params.args[1][0] === 'detailed_type') {
|
||||
assert.ok(true);
|
||||
return Promise.resolve([{detailed_type: 'event'}]);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
intercepts: {
|
||||
do_action: function (ev) {
|
||||
if (ev.data.action === 'event_sale.event_configurator_action') {
|
||||
assert.ok(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
await testUtils.dom.click(form.$("a:contains('Add a product')"));
|
||||
await testUtils.fields.many2one.searchAndClickItem("product_id", {item: 'Customizable Event'});
|
||||
form.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,24 +42,23 @@ tour.register('event_configurator_tour', {
|
||||
}, {
|
||||
trigger: '.o_event_sale_js_event_configurator_ok'
|
||||
}, {
|
||||
trigger: 'textarea[name="name"]',
|
||||
run: function () {
|
||||
var $textarea = $('textarea[name="name"]');
|
||||
if ($textarea.val().includes('Design Fair Los Angeles') && $textarea.val().includes('VIP')) {
|
||||
$textarea.addClass('tour_success');
|
||||
}
|
||||
}
|
||||
}, {
|
||||
trigger: 'textarea[name="name"].tour_success',
|
||||
trigger: "td[name='name'][data-tooltip*='VIP']",
|
||||
run: function () {} // check
|
||||
}, {
|
||||
trigger: 'ul.nav a:contains("Order Lines")',
|
||||
run: 'click'
|
||||
}, {
|
||||
content: "search the partner",
|
||||
trigger: 'div[name="partner_id"] input',
|
||||
run: 'text Azure'
|
||||
}, {
|
||||
content: "select the partner",
|
||||
trigger: 'ul.ui-autocomplete > li > a:contains(Azure)',
|
||||
}, {
|
||||
trigger: 'td:contains("Event")',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration'
|
||||
trigger: 'button.fa-pencil'
|
||||
}, {
|
||||
trigger: 'div[name="event_ticket_id"] input',
|
||||
run: 'click'
|
||||
@@ -70,17 +69,9 @@ tour.register('event_configurator_tour', {
|
||||
}, {
|
||||
trigger: '.o_event_sale_js_event_configurator_ok'
|
||||
}, {
|
||||
trigger: 'textarea[name="name"]',
|
||||
run: function () {
|
||||
var $textarea = $('textarea[name="name"]');
|
||||
if ($textarea.val().includes('Standard')) {
|
||||
$textarea.addClass('tour_success_2');
|
||||
}
|
||||
}
|
||||
}, {
|
||||
trigger: 'textarea[name="name"].tour_success_2',
|
||||
trigger: "td[name='name'][data-tooltip*='Standard']",
|
||||
run: function () {} // check
|
||||
}, ...tour.stepUtils.discardForm()
|
||||
}, ...tour.stepUtils.saveForm()
|
||||
]);
|
||||
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
('date_end','>=',time.strftime('%Y-%m-%d 00:00:00')),
|
||||
'|', ('company_id', '=', False), ('company_id', '=', parent.company_id)
|
||||
]"
|
||||
attrs="{'invisible': [('event_ok', '=', False)], 'required': [('event_ok', '!=', False)]}"
|
||||
attrs="{'invisible': [('product_type', '!=', 'event')], 'required': [('product_type', '=', 'event')]}"
|
||||
options="{'no_open': True, 'no_create': True}"
|
||||
/>
|
||||
<field
|
||||
@@ -29,15 +29,13 @@
|
||||
'|', ('company_id', '=', False), ('company_id', '=', parent.company_id)
|
||||
]"
|
||||
attrs="{
|
||||
'invisible': ['|', ('event_ok', '=', False), ('event_id', '=', False)],
|
||||
'required': [('event_ok', '!=', False), ('event_id', '!=', False)],
|
||||
'invisible': ['|', ('product_type', '!=', 'event'), ('event_id', '=', False)],
|
||||
'required': [('product_type', '=', 'event'), ('event_id', '!=', False)],
|
||||
}"
|
||||
options="{'no_open': True, 'no_create': True}"
|
||||
/>
|
||||
<field name="event_ok" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='order_line']//tree//field[@name='product_template_id']" position="after">
|
||||
<field name="event_ok" invisible="1" />
|
||||
<field name="event_id" optional="hide" domain="['|', ('company_id', '=', False), ('company_id', '=', parent.company_id)]"/>
|
||||
<field name="event_ticket_id" optional="hide" domain="['|', ('company_id', '=', False), ('company_id', '=', parent.company_id)]"/>
|
||||
</xpath>
|
||||
|
||||
@@ -20,7 +20,6 @@ Please refer to Sale Matrix or Purchase Matrix for the use of this module.
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'product_matrix/static/src/js/section_and_note_widget.js',
|
||||
'product_matrix/static/src/scss/product_matrix.scss',
|
||||
'product_matrix/static/src/xml/**/*',
|
||||
],
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
odoo.define('product_matrix.section_and_note_widget', function (require) {
|
||||
|
||||
var Dialog = require('web.Dialog');
|
||||
var core = require('web.core');
|
||||
var _t = core._t;
|
||||
var qweb = core.qweb;
|
||||
const {Markup} = require('web.utils');
|
||||
var fieldRegistry = require('web.field_registry');
|
||||
var {format} = require('web.field_utils');
|
||||
require('account.section_and_note_backend');
|
||||
|
||||
var SectionAndNoteFieldOne2Many = fieldRegistry.get('section_and_note_one2many');
|
||||
|
||||
SectionAndNoteFieldOne2Many.include({
|
||||
custom_events: _.extend({}, SectionAndNoteFieldOne2Many.prototype.custom_events, {
|
||||
open_matrix: '_openMatrix',
|
||||
}),
|
||||
|
||||
/**
|
||||
* Sends the matrix modifications to the server
|
||||
* Through a change on an invisible non stored field.
|
||||
*
|
||||
* @param {List} list of changes in the matrix, to be applied to the order.
|
||||
* {integer} quantity: float
|
||||
* {List} ptav_ids: product.template.attribute.value ids
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_applyGrid: function (changes, productTemplateId) {
|
||||
// the getParent is to trigger the event on the form Controller instead of the one2many.
|
||||
// If not, the one2many crashes on reset because it tries to find an operation in the event
|
||||
// even if there isn't any.
|
||||
// the only solution would be to use a custom event catched on a new controller
|
||||
// on the so/po form (as a js_class).
|
||||
this.trigger_up('field_changed', {
|
||||
dataPointID: this.dataPointID,
|
||||
changes: {
|
||||
grid: JSON.stringify({changes: changes, product_template_id: productTemplateId}),
|
||||
grid_update: true // to say that the changes to grid have to be applied to the SO.
|
||||
},
|
||||
viewType: 'form',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Catches the event asking for matrix opening
|
||||
*
|
||||
* @param {OdooEvent} ev various values needed to open the matrix
|
||||
* {integer} data.product_template_id product.template id
|
||||
* {list} data.editedCellAttributes list of product.template.attribute.value ids
|
||||
* {bool} data.edit whether the line source should be deleted or not.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_openMatrix: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var self = this;
|
||||
var dataPointId = ev.data.dataPointId;
|
||||
var productTemplateId = ev.data.product_template_id;
|
||||
var editedCellAttributes = ev.data.editedCellAttributes;
|
||||
if (!ev.data.edit) {
|
||||
// remove the line used to open the matrix
|
||||
this._setValue({operation: 'DELETE', ids: [dataPointId]});
|
||||
}
|
||||
// the getParent is to trigger the event on the form Controller instead of the one2many.
|
||||
// If not, the one2many crashes on reset because it tries to find an operation in the event
|
||||
// even if there isn't any.
|
||||
// the only solution would be to use a custom event catched on a new controller
|
||||
// on the so/po form (as a js_class).
|
||||
this.trigger_up('field_changed', {
|
||||
dataPointID: this.dataPointID,
|
||||
changes: {
|
||||
grid_product_tmpl_id: {id: productTemplateId}
|
||||
},
|
||||
viewType: 'form',
|
||||
onSuccess: function () {
|
||||
const gridInfo = self.recordData.grid;
|
||||
self._openMatrixConfigurator(gridInfo, productTemplateId, editedCellAttributes);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggers Matrix Dialog opening
|
||||
*
|
||||
* @param {String} jsonInfo matrix dialog content
|
||||
* @param {integer} productTemplateId product.template id
|
||||
* @param {editedCellAttributes} list of product.template.attribute.value ids
|
||||
* used to focus on the matrix cell representing the edited line.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_openMatrixConfigurator: function (jsonInfo, productTemplateId, editedCellAttributes) {
|
||||
var self = this;
|
||||
var infos = JSON.parse(jsonInfo);
|
||||
var MatrixDialog = new Dialog(this, {
|
||||
title: _t('Choose Product Variants'),
|
||||
size: 'extra-large', // adapt size depending on matrix size?
|
||||
$content: $(qweb.render(
|
||||
'product_matrix.matrix', {
|
||||
header: infos.header,
|
||||
rows: infos.matrix,
|
||||
format({price, currency_id}) {
|
||||
if (!price) { return ""; }
|
||||
const sign = price < 0 ? '-' : '+';
|
||||
const formatted = format.monetary(Math.abs(price), null, {currency_id});
|
||||
return Markup`${sign} ${formatted}`;
|
||||
}
|
||||
}
|
||||
)),
|
||||
buttons: [
|
||||
{text: _t('Confirm'), classes: 'btn-primary', close: true, click: function (result) {
|
||||
var $inputs = this.$('.o_matrix_input');
|
||||
var matrixChanges = [];
|
||||
_.each($inputs, function (matrixInput) {
|
||||
if (matrixInput.value && matrixInput.value !== matrixInput.attributes.value.nodeValue) {
|
||||
matrixChanges.push({
|
||||
qty: parseFloat(matrixInput.value),
|
||||
ptav_ids: matrixInput.attributes.ptav_ids.nodeValue.split(",").map(function (id) {
|
||||
return parseInt(id);
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
if (matrixChanges.length > 0) {
|
||||
self._applyGrid(matrixChanges, productTemplateId);
|
||||
}
|
||||
}},
|
||||
{text: _t('Close'), close: true},
|
||||
],
|
||||
}).open();
|
||||
|
||||
MatrixDialog.opened(function () {
|
||||
MatrixDialog.$content.closest('.o_dialog_container').removeClass('d-none');
|
||||
if (editedCellAttributes.length > 0) {
|
||||
var str = editedCellAttributes.toString();
|
||||
MatrixDialog.$content.find('.o_matrix_input').filter((k, v) => v.attributes.ptav_ids.nodeValue === str)[0].focus();
|
||||
} else {
|
||||
MatrixDialog.$content.find('.o_matrix_input:first()').focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return SectionAndNoteFieldOne2Many;
|
||||
|
||||
});
|
||||
@@ -132,7 +132,7 @@
|
||||
<field name="name">purchase.order.form</field>
|
||||
<field name="model">purchase.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Purchase Order" class="o_purchase_order" js_class="legacy_form">
|
||||
<form string="Purchase Order" class="o_purchase_order">
|
||||
<header>
|
||||
<button name="action_rfq_send" states="draft" string="Send by Email" type="object" context="{'send_rfq':True}" class="oe_highlight" data-hotkey="g"/>
|
||||
<button name="print_quotation" string="Print RFQ" type="object" states="draft" class="oe_highlight" groups="base.group_user" data-hotkey="k"/>
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
'web.assets_backend': [
|
||||
'purchase_product_matrix/static/src/**/*',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'purchase_product_matrix/static/tests/section_and_note_widget_tests.js',
|
||||
],
|
||||
'web.assets_tests': [
|
||||
'purchase_product_matrix/static/tests/tours/**/*',
|
||||
],
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
odoo.define('purchase.product_matrix_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 purchase.product_matrix_configurator widget is a widget extending FieldMany2One
|
||||
* It triggers the opening of the matrix edition when the product has multiple variants.
|
||||
*
|
||||
*
|
||||
* !!! WARNING !!!
|
||||
*
|
||||
* This widget is only designed for Purchase Order Lines.
|
||||
* !!! It should only be used on a product_template field !!!
|
||||
*/
|
||||
var MatrixConfiguratorWidget = relationalFields.FieldMany2One.extend({
|
||||
events: _.extend({}, relationalFields.FieldMany2One.prototype.events, {
|
||||
'click .o_edit_product_configuration': '_onEditProductConfiguration'
|
||||
}),
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_render: function () {
|
||||
this._super.apply(this, arguments);
|
||||
if (this.mode === 'edit' && this.value &&
|
||||
(this._isConfigurableProduct())) {
|
||||
this._addProductLinkButton();
|
||||
this._addConfigurationEditButton();
|
||||
} else if (this.mode === 'edit' && this.value) {
|
||||
this._addProductLinkButton();
|
||||
} else {
|
||||
this.$('.o_edit_product_configuration').hide();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add button linking to product_id/product_template_id form.
|
||||
*/
|
||||
_addProductLinkButton: function () {
|
||||
if (this.$('.o_external_button').length === 0) {
|
||||
var $productLinkButton = $('<button>', {
|
||||
type: 'button',
|
||||
class: 'fa fa-external-link btn btn-secondary o_external_button',
|
||||
tabindex: '-1',
|
||||
draggable: false,
|
||||
'aria-label': _t('External Link'),
|
||||
title: _t('External Link')
|
||||
});
|
||||
|
||||
var $inputDropdown = this.$('.o_input_dropdown');
|
||||
$inputDropdown.after($productLinkButton);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* If current product is configurable,
|
||||
* Show edit button (in Edit Mode) after the product/product_template
|
||||
*/
|
||||
_addConfigurationEditButton: function () {
|
||||
var $inputDropdown = this.$('.o_input_dropdown');
|
||||
|
||||
if ($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);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook to override with _onEditProductConfiguration
|
||||
* to know if edit pencil button has to be put next to the field
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_isConfigurableProduct: function () {
|
||||
return this.recordData.is_configurable_product;
|
||||
},
|
||||
|
||||
/**
|
||||
* Override catching changes on product_id or product_template_id.
|
||||
* Calls _onTemplateChange in case of product_template change.
|
||||
* Calls _onProductChange in case of product change.
|
||||
* Shouldn't be overridden by product configurators
|
||||
* or only to setup some data for further computation
|
||||
* before calling super.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
reset: async function (record, ev) {
|
||||
await this._super(...arguments);
|
||||
if (ev && ev.target === this && ev.data.changes && ev.data.changes.product_template_id && record.data.product_template_id.data.id) {
|
||||
this._onTemplateChange(record.data.product_template_id.data.id, ev.data.dataPointID);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook for product_template based configurators
|
||||
* (product configurator, matrix, ...).
|
||||
*
|
||||
* @param {integer} productTemplateId
|
||||
* @param {String} dataPointID
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onTemplateChange: function (productTemplateId, dataPointId) {
|
||||
var self = this;
|
||||
this._rpc({
|
||||
model: 'product.template',
|
||||
method: 'get_single_product_variant',
|
||||
args: [
|
||||
productTemplateId
|
||||
]
|
||||
}).then(function (result) {
|
||||
if (result.product_id) {
|
||||
self.trigger_up('field_changed', {
|
||||
dataPointID: dataPointId,
|
||||
changes: {
|
||||
product_id: {id: result.product_id},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
self._openMatrix(productTemplateId, dataPointId, false);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook for editing a configured line.
|
||||
* The button triggering this function is only shown in Edit mode,
|
||||
* when _isConfigurableProduct is True.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEditProductConfiguration: function () {
|
||||
if (this.recordData.is_configurable_product) {
|
||||
this._openMatrix(this.recordData.product_template_id.data.id, this.dataPointID, true);
|
||||
}
|
||||
},
|
||||
|
||||
_openMatrix: function (productTemplateId, dataPointId, edit) {
|
||||
var attribs = edit ? this._getPTAVS() : [];
|
||||
this.trigger_up('open_matrix', {
|
||||
product_template_id: productTemplateId,
|
||||
model: 'purchase.order',
|
||||
dataPointId: dataPointId,
|
||||
edit: edit,
|
||||
editedCellAttributes: attribs,
|
||||
// used to focus the cell representing the line on which the pencil was clicked.
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the list of attribute ids (product.template.attribute.value)
|
||||
* from the current POLine.
|
||||
*/
|
||||
_getPTAVS: function () {
|
||||
var PTAVSIDS = [];
|
||||
_.each(this.recordData.product_no_variant_attribute_value_ids.res_ids, function (id) {
|
||||
PTAVSIDS.push(id);
|
||||
});
|
||||
_.each(this.recordData.product_template_attribute_value_ids.res_ids, function (id) {
|
||||
PTAVSIDS.push(id);
|
||||
});
|
||||
return PTAVSIDS.sort(function (a, b) {return a - b;});
|
||||
}
|
||||
});
|
||||
|
||||
FieldsRegistry.add('matrix_configurator', MatrixConfiguratorWidget);
|
||||
|
||||
return MatrixConfiguratorWidget;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import Dialog from 'web.Dialog';
|
||||
import { qweb } from "web.core";
|
||||
import { registry } from '@web/core/registry';
|
||||
import { Many2OneField } from '@web/views/fields/many2one/many2one_field';
|
||||
import { formatMonetary } from "@web/views/fields/formatters";
|
||||
|
||||
const { onWillUpdateProps } = owl;
|
||||
|
||||
|
||||
export class PurchaseOrderLineProductField extends Many2OneField {
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
onWillUpdateProps(async (nextProps) => {
|
||||
if (nextProps.record.mode === 'edit' && nextProps.value) {
|
||||
if (
|
||||
!this.props.value ||
|
||||
this.props.value[0] != nextProps.value[0]
|
||||
) {
|
||||
// Field was updated if line was open in edit mode,
|
||||
// field is not emptied,
|
||||
// new value is different than existing value.
|
||||
|
||||
this._onProductTemplateUpdate();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get configurationButtonHelp() {
|
||||
return this.env._t("Edit Configuration");
|
||||
}
|
||||
get isConfigurableTemplate() {
|
||||
return this.props.record.data.is_configurable_product;
|
||||
}
|
||||
|
||||
async _onProductTemplateUpdate() {
|
||||
const result = await this.orm.call(
|
||||
'product.template',
|
||||
'get_single_product_variant',
|
||||
[this.props.record.data.product_template_id[0]],
|
||||
);
|
||||
if(result && result.product_id) {
|
||||
if (this.props.record.data.product_id != result.product_id.id) {
|
||||
this.props.record.update({
|
||||
// TODO right name get (same problem as configurator)
|
||||
product_id: [result.product_id, 'whatever'],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this._openGridConfigurator(false);
|
||||
}
|
||||
}
|
||||
|
||||
onEditConfiguration() {
|
||||
if (this.props.record.data.is_configurable_product) {
|
||||
this._openGridConfigurator(true);
|
||||
}
|
||||
}
|
||||
|
||||
async _openGridConfigurator(edit) {
|
||||
const PurchaseOrderRecord = this.props.record.model.root;
|
||||
|
||||
// fetch matrix information from server;
|
||||
await PurchaseOrderRecord.update({
|
||||
grid_product_tmpl_id: this.props.record.data.product_template_id,
|
||||
});
|
||||
|
||||
let updatedLineAttributes = [];
|
||||
if (edit) {
|
||||
// provide attributes of edited line to automatically focus on matching cell in the matrix
|
||||
for (let ptnvav of this.props.record.data.product_no_variant_attribute_value_ids.records) {
|
||||
updatedLineAttributes.push(ptnvav.data.id);
|
||||
}
|
||||
for (let ptav of this.props.record.data.product_template_attribute_value_ids.records) {
|
||||
updatedLineAttributes.push(ptav.data.id);
|
||||
}
|
||||
updatedLineAttributes.sort((a, b) => { return a - b; });
|
||||
}
|
||||
|
||||
this._openMatrixConfigurator(
|
||||
PurchaseOrderRecord.data.grid,
|
||||
this.props.record.data.product_template_id[0],
|
||||
updatedLineAttributes,
|
||||
);
|
||||
|
||||
if (!edit) {
|
||||
// remove new line used to open the matrix
|
||||
PurchaseOrderRecord.data.order_line.removeRecord(this.props.record);
|
||||
}
|
||||
}
|
||||
|
||||
_openMatrixConfigurator(jsonInfo, productTemplateId, editedCellAttributes) {
|
||||
const infos = JSON.parse(jsonInfo);
|
||||
const saleOrderRecord = this.props.record.model.root;
|
||||
const MatrixDialog = new Dialog(this, {
|
||||
title: this.env._t('Choose Product Variants'),
|
||||
size: 'extra-large', // adapt size depending on matrix size?
|
||||
$content: $(qweb.render(
|
||||
'product_matrix.matrix', {
|
||||
header: infos.header,
|
||||
rows: infos.matrix,
|
||||
format({price, currency_id}) {
|
||||
if (!price) { return ""; }
|
||||
return formatMonetary(price, {
|
||||
currencyId: currency_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
)),
|
||||
buttons: [
|
||||
{text: this.env._t('Confirm'), classes: 'btn-primary', close: true, click: function (result) {
|
||||
const $inputs = this.$('.o_matrix_input');
|
||||
var matrixChanges = [];
|
||||
_.each($inputs, function (matrixInput) {
|
||||
if (matrixInput.value && matrixInput.value !== matrixInput.attributes.value.nodeValue) {
|
||||
matrixChanges.push({
|
||||
qty: parseFloat(matrixInput.value),
|
||||
ptav_ids: matrixInput.attributes.ptav_ids.nodeValue.split(",").map(function (id) {
|
||||
return parseInt(id);
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
if (matrixChanges.length > 0) {
|
||||
// NB: server also removes current line opening the matrix
|
||||
saleOrderRecord.update({
|
||||
grid: JSON.stringify({changes: matrixChanges, product_template_id: productTemplateId}),
|
||||
grid_update: true // to say that the changes to grid have to be applied to the SO.
|
||||
});
|
||||
}
|
||||
}},
|
||||
{text: this.env._t('Close'), close: true},
|
||||
],
|
||||
}).open();
|
||||
|
||||
MatrixDialog.opened(function () {
|
||||
MatrixDialog.$content.closest('.o_dialog_container').removeClass('d-none');
|
||||
if (editedCellAttributes.length > 0) {
|
||||
const str = editedCellAttributes.toString();
|
||||
MatrixDialog.$content.find('.o_matrix_input').filter((k, v) => v.attributes.ptav_ids.nodeValue === str)[0].focus();
|
||||
} else {
|
||||
MatrixDialog.$content.find('.o_matrix_input:first()').focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
PurchaseOrderLineProductField.template = "purchase.PurchaseProductField";
|
||||
|
||||
registry.category("fields").add("pol_product_many2one", PurchaseOrderLineProductField);
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="purchase.PurchaseProductField" t-inherit="web.Many2OneField" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//t[@t-if='hasExternalButton']" position="before">
|
||||
<t t-if="isConfigurableTemplate">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary fa fa-pencil"
|
||||
tabindex="-1"
|
||||
draggable="false"
|
||||
t-att-aria-label="configurationButtonHelp"
|
||||
t-att-data-tooltip="configurationButtonHelp"
|
||||
t-on-click="onEditConfiguration"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,260 +0,0 @@
|
||||
odoo.define('purchase_product_matrix.section_and_note_widget_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var FormView = require('web.FormView');
|
||||
var testUtils = require('web.test_utils');
|
||||
var createView = testUtils.createView;
|
||||
|
||||
function getGrid(product) {
|
||||
return JSON.stringify({
|
||||
header: [{name: product.name}, {name: "M"}, {name: "L"}],
|
||||
matrix: [[
|
||||
{name: "Men"},
|
||||
{ptav_ids: [10, 13], qty: 0, is_possible_combination: true},
|
||||
{ptav_ids: [11, 13], qty: 0, is_possible_combination: true},
|
||||
], [
|
||||
{name: "Women"},
|
||||
{ptav_ids: [10, 14], qty: 0, is_possible_combination: true},
|
||||
{ptav_ids: [11, 14], qty: 0, is_possible_combination: true},
|
||||
]],
|
||||
});
|
||||
}
|
||||
|
||||
QUnit.module('section_and_note: purchase_product_matrix', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
purchase_order: {
|
||||
fields: {
|
||||
order_line_ids: {
|
||||
string: "Lines",
|
||||
type: 'one2many',
|
||||
relation: 'order_line',
|
||||
relation_field: 'order_id',
|
||||
},
|
||||
grid: {string: "Grid", type: 'char'},
|
||||
grid_product_tmpl_id: {string: "Grid Product", type: 'many2one', relation: 'product'},
|
||||
},
|
||||
onchanges: {
|
||||
grid_product_tmpl_id: (obj) => {
|
||||
const product = this.data.product.records.find((p) => {
|
||||
return p.id === obj.grid_product_tmpl_id;
|
||||
});
|
||||
obj.grid = product ? getGrid(product) : false;
|
||||
},
|
||||
grid: () => {},
|
||||
},
|
||||
},
|
||||
order_line: {
|
||||
fields: {
|
||||
order_id: {string: "Invoice", type: 'many2one', relation: 'invoice'},
|
||||
product_template_id: {string: "Product", type: 'many2one', relation: 'product'},
|
||||
},
|
||||
},
|
||||
product: {
|
||||
fields: {
|
||||
name: {string: "Name", type: 'char'},
|
||||
},
|
||||
records: [
|
||||
{id: 1, name: 'A configurable product'},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
}, function () {
|
||||
QUnit.test('can configure a product with the matrix', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'purchase_order',
|
||||
data: this.data,
|
||||
arch: `<form>
|
||||
<field name="grid" invisible="1"/>
|
||||
<field name="grid_product_tmpl_id" invisible="1"/>
|
||||
<field name="order_line_ids" widget="section_and_note_one2many">
|
||||
<tree editable="bottom">
|
||||
<field name="product_template_id" widget="matrix_configurator"/>
|
||||
</tree>
|
||||
</field>
|
||||
</form>`,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'onchange' && args.args[2] === 'grid') {
|
||||
// should trigger an onchange on the grid field and let the
|
||||
// business logic create rows according to the matrix content
|
||||
assert.deepEqual(args.args[1].grid, JSON.stringify({
|
||||
changes: [{qty: 2, ptav_ids: [10, 13]}, {qty: 3, ptav_ids: [11, 14]}],
|
||||
product_template_id: 1,
|
||||
}));
|
||||
}
|
||||
if (args.method === 'get_single_product_variant') {
|
||||
assert.strictEqual(args.args[0], 1);
|
||||
return Promise.resolve({mode: 'matrix'});
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
await testUtils.dom.click('.o_field_x2many_list_row_add a');
|
||||
await testUtils.fields.many2one.searchAndClickItem("product_template_id", {item: 'configurable'});
|
||||
|
||||
assert.containsOnce(document.body, '.modal .o_product_variant_matrix');
|
||||
const $matrix = $('.modal .o_product_variant_matrix');
|
||||
assert.strictEqual($matrix.text().replace(/[\n\r\s\u00a0]+/g, ' '),
|
||||
' A configurable product M L Men Women ');
|
||||
|
||||
// select 2 M-Men and 3 L-Women
|
||||
await testUtils.fields.editInput($matrix.find('.o_matrix_input[ptav_ids="10,13"]'), '2');
|
||||
await testUtils.fields.editInput($matrix.find('.o_matrix_input[ptav_ids="11,14"]'), '3');
|
||||
await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('can open the matrix twice with 2 different products', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
this.data.product.records.push({ id: 101, name: "Product A" });
|
||||
this.data.product.records.push({ id: 102, name: "Product B" });
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'purchase_order',
|
||||
data: this.data,
|
||||
arch: `<form>
|
||||
<field name="grid" invisible="1"/>
|
||||
<field name="grid_product_tmpl_id" invisible="1"/>
|
||||
<field name="order_line_ids" widget="section_and_note_one2many">
|
||||
<tree editable="bottom">
|
||||
<field name="product_template_id" widget="matrix_configurator"/>
|
||||
</tree>
|
||||
</field>
|
||||
</form>`,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'onchange' && args.args[2] === 'grid') {
|
||||
// should trigger an onchange on the grid field and let the
|
||||
// business logic create rows according to the matrix content
|
||||
assert.deepEqual(args.args[1].grid, JSON.stringify({
|
||||
changes: [{qty: 2, ptav_ids: [10, 13]}, {qty: 3, ptav_ids: [11, 14]}],
|
||||
product_template_id: 102,
|
||||
}));
|
||||
}
|
||||
if (args.method === 'get_single_product_variant') {
|
||||
return Promise.resolve({mode: 'matrix'});
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
// open the matrix with "Product A" and close it
|
||||
await testUtils.dom.click('.o_field_x2many_list_row_add a');
|
||||
await testUtils.fields.many2one.searchAndClickItem("product_template_id", {item: 'Product A'});
|
||||
|
||||
assert.containsOnce(document.body, '.modal .o_product_variant_matrix');
|
||||
let $matrix = $('.modal .o_product_variant_matrix');
|
||||
assert.strictEqual($matrix.text().replace(/[\n\r\s\u00a0]+/g, ' '),
|
||||
' Product A M L Men Women ');
|
||||
|
||||
await testUtils.dom.click($('.modal .modal-footer .btn-secondary')); // close
|
||||
|
||||
// re-open the matrix with "Product B"
|
||||
await testUtils.dom.click('.o_field_x2many_list_row_add a');
|
||||
await testUtils.fields.many2one.searchAndClickItem("product_template_id", {item: 'Product B'});
|
||||
|
||||
assert.containsOnce(document.body, '.modal .o_product_variant_matrix');
|
||||
$matrix = $('.modal .o_product_variant_matrix');
|
||||
assert.strictEqual($matrix.text().replace(/[\n\r\s\u00a0]+/g, ' '),
|
||||
' Product B M L Men Women ');
|
||||
|
||||
// select 2 M-Men and 3 L-Women
|
||||
await testUtils.fields.editInput($matrix.find('.o_matrix_input[ptav_ids="10,13"]'), '2');
|
||||
await testUtils.fields.editInput($matrix.find('.o_matrix_input[ptav_ids="11,14"]'), '3');
|
||||
await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('_onTemplateChange is executed after product template quick create', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
let created_product_template;
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'purchase_order',
|
||||
data: this.data,
|
||||
arch: `<form>
|
||||
<field name="order_line_ids" widget="section_and_note_one2many">
|
||||
<tree editable="bottom">
|
||||
<field name="product_template_id" widget="matrix_configurator"/>
|
||||
</tree>
|
||||
</field>
|
||||
</form>`,
|
||||
async mockRPC(route, args) {
|
||||
if (route === '/web/dataset/call_kw/product.template/get_single_product_variant') {
|
||||
assert.strictEqual(args.args[0], created_product_template[0]);
|
||||
}
|
||||
|
||||
const result = await this._super(...arguments);
|
||||
if (args.method === 'name_create') {
|
||||
created_product_template = result;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
await testUtils.dom.click('.o_field_x2many_list_row_add a');
|
||||
await testUtils.fields.many2one.searchAndClickItem("product_template_id", {search: 'new product'});
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('drag and drop rows containing matrix_configurator many2one', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
this.data.order_line.fields.sequence = {string: "Sequence", type: 'number'};
|
||||
this.data.order_line.fields.order_id.relation = 'purchase_order';
|
||||
this.data.purchase_order.records = [
|
||||
{id: 1, order_line_ids: [1, 2]}
|
||||
];
|
||||
this.data.order_line.records = [
|
||||
{id: 1, sequence: 4, product_template_id: 1, order_id: 1},
|
||||
{id: 2, sequence: 14, product_template_id: 2, order_id: 1},
|
||||
];
|
||||
this.data.product.records.push(
|
||||
{id: 1, name: "Chair"},
|
||||
{id: 2, name: "Table"}
|
||||
);
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'purchase_order',
|
||||
data: this.data,
|
||||
arch: `<form>
|
||||
<field name="order_line_ids" widget="section_and_note_one2many">
|
||||
<tree editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="product_template_id" widget="matrix_configurator"/>
|
||||
</tree>
|
||||
</field>
|
||||
</form>`,
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsN(form, '.o_data_row', 2);
|
||||
assert.strictEqual(form.$('.o_data_row').text(), 'ChairTable');
|
||||
assert.containsN(form, '.o_data_row .o_row_handle', 2);
|
||||
|
||||
// move first row below second
|
||||
const $firstHandle = form.$('.o_data_row:nth(0) .o_row_handle');
|
||||
const $secondHandle = form.$('.o_data_row:nth(1) .o_row_handle');
|
||||
await testUtils.dom.dragAndDrop($firstHandle, $secondHandle, { position: 'bottom' });
|
||||
|
||||
assert.strictEqual(form.$('.o_data_row').text(), 'TableChair');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,21 +11,19 @@ tour.register('purchase_matrix_tour', {
|
||||
}, {
|
||||
trigger: ".o_list_button_add",
|
||||
extra_trigger: ".o_purchase_order"
|
||||
}, {
|
||||
trigger: '.o_required_modifier[name=partner_id] input',
|
||||
run: 'text Agrolait',
|
||||
}, {
|
||||
trigger: '.ui-menu-item > a:contains("Agrolait")',
|
||||
auto: true,
|
||||
}, {
|
||||
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('Matrix');
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
$input.trigger(keyDownEvent);
|
||||
}
|
||||
run: "text Matrix",
|
||||
}, {
|
||||
trigger: 'ul.ui-autocomplete a:contains("Matrix")',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: '.o_product_variant_matrix',
|
||||
run: function () {
|
||||
@@ -35,29 +33,16 @@ tour.register('purchase_matrix_tour', {
|
||||
}, {
|
||||
trigger: 'span:contains("Confirm")',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: ".o_form_editable .o_field_many2one[name='partner_id'] input",
|
||||
extra_trigger: ".o_purchase_order",
|
||||
run: 'text Agrolait'
|
||||
}, {
|
||||
trigger: ".ui-menu-item > a",
|
||||
auto: true,
|
||||
in_modal: false,
|
||||
}, {
|
||||
trigger: '.o_form_button_save:contains("Save")',
|
||||
run: 'click' // SAVE Sales Order.
|
||||
},
|
||||
// Open the matrix through the pencil button next to the product in line edit mode.
|
||||
{
|
||||
trigger: '.o_form_button_edit:contains("Edit")',
|
||||
run: 'click' // Edit Sales Order.
|
||||
}, {
|
||||
trigger: 'span:contains("Matrix (PAV11, PAV22, PAV31)\nPA4: PAV41")',
|
||||
extra_trigger: '.o_form_editable',
|
||||
run: 'click'
|
||||
extra_trigger: '.o_form_status_indicator_buttons_hidden', // wait for save to be finished
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration',
|
||||
run: 'click' // edit the matrix
|
||||
trigger: '[name=product_template_id] button.fa-pencil', // edit the matrix
|
||||
}, {
|
||||
trigger: '.o_product_variant_matrix',
|
||||
run: function () {
|
||||
@@ -71,29 +56,18 @@ tour.register('purchase_matrix_tour', {
|
||||
trigger: '.o_form_button_save:contains("Save")',
|
||||
extra_trigger: '.o_field_cell.o_data_cell.o_list_number:contains("4.00")',
|
||||
run: 'click' // SAVE Sales Order, after matrix has been applied (extra_trigger).
|
||||
}, {
|
||||
trigger: '.o_form_button_edit:contains("Edit")',
|
||||
run: 'click' // Edit Sales Order.
|
||||
},
|
||||
// Ensures the matrix is opened with the values, when adding the same product.
|
||||
{
|
||||
trigger: "a:contains('Add a product')",
|
||||
extra_trigger: '.o_form_editable',
|
||||
trigger: 'a:contains("Add a product")',
|
||||
extra_trigger: '.o_form_status_indicator_buttons_hidden',
|
||||
}, {
|
||||
trigger: 'div[name="product_template_id"] input',
|
||||
run: function () {
|
||||
var $input = $('div[name="product_template_id"] input');
|
||||
$input.click();
|
||||
$input.val('Matrix');
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
$input.trigger(keyDownEvent);
|
||||
}
|
||||
run: 'text Matrix',
|
||||
}, {
|
||||
trigger: 'ul.ui-autocomplete a:contains("Matrix")',
|
||||
run: 'click'
|
||||
}, {
|
||||
trigger: "input[value='4']",
|
||||
trigger: 'input[value="4"]',
|
||||
run: function () {
|
||||
// update some values of the matrix
|
||||
$("input[value='4']").slice(0, 4).val(8.2);
|
||||
|
||||
@@ -1,39 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- TODO ensure barcode working with matrix (configurator mobile task ?) -->
|
||||
|
||||
<record id="purchase_order_form_matrix" model="ir.ui.view">
|
||||
<field name="name">purchase.order.form.inherit.matrix</field>
|
||||
<field name="model">purchase.order</field>
|
||||
<field name="inherit_id" ref="purchase.purchase_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree/field[@name='product_id']" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//tree/field[@name='product_id']" position="after">
|
||||
<field name="product_template_id"
|
||||
string="Product"
|
||||
attrs="{
|
||||
'readonly': [('state', 'in', ('purchase', 'to approve','done', 'cancel'))],
|
||||
'required': [('display_type', '=', False)],
|
||||
}"
|
||||
options="{'no_open': True}"
|
||||
context="{'partner_id': parent.partner_id}"
|
||||
widget="matrix_configurator"/>
|
||||
<field name="product_template_attribute_value_ids" invisible="1" />
|
||||
<field name="product_no_variant_attribute_value_ids" invisible="1" />
|
||||
<field name="is_configurable_product" invisible="1" />
|
||||
</xpath>
|
||||
<field name="partner_id" position="after">
|
||||
<field name="grid" invisible="1"/>
|
||||
<field name="grid_product_tmpl_id" invisible="1"/>
|
||||
<field name="grid_update" invisible="1"/>
|
||||
</field>
|
||||
<xpath expr="//group[@name='other_info']" position="inside">
|
||||
<field name="report_grids" groups="base.group_no_one"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
<record id="purchase_order_form_matrix" model="ir.ui.view">
|
||||
<field name="name">purchase.order.form.inherit.matrix</field>
|
||||
<field name="model">purchase.order</field>
|
||||
<field name="inherit_id" ref="purchase.purchase_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree/field[@name='product_id']" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//tree/field[@name='product_id']" position="after">
|
||||
<field name="product_template_id"
|
||||
string="Product"
|
||||
attrs="{
|
||||
'readonly': [('state', 'in', ('purchase', 'to approve','done', 'cancel'))],
|
||||
'required': [('display_type', '=', False)],
|
||||
}"
|
||||
options="{'no_open': True}"
|
||||
context="{'partner_id': parent.partner_id}"
|
||||
widget="pol_product_many2one"/>
|
||||
<field name="product_template_attribute_value_ids" invisible="1"/>
|
||||
<field name="product_no_variant_attribute_value_ids" invisible="1"/>
|
||||
<field name="is_configurable_product" invisible="1"/>
|
||||
</xpath>
|
||||
<field name="partner_id" position="after">
|
||||
<field name="grid" invisible="1"/>
|
||||
<field name="grid_product_tmpl_id" invisible="1"/>
|
||||
<field name="grid_update" invisible="1"/>
|
||||
</field>
|
||||
<group name="other_info" position="inside">
|
||||
<field name="report_grids" groups="base.group_no_one"/>
|
||||
</group>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -67,11 +67,8 @@ This module contains all the common features of Sales Management and eCommerce.
|
||||
'sale/static/src/scss/product_configurator.scss',
|
||||
'sale/static/src/js/sale_progressbar_field.js',
|
||||
'sale/static/src/js/tours/sale.js',
|
||||
'sale/static/src/js/sale_order_line_mixin.js',
|
||||
'sale/static/src/js/product_configurator_widget.js',
|
||||
'sale/static/src/js/sale_order_controller.js',
|
||||
'sale/static/src/js/sale_order_view.js',
|
||||
'sale/static/src/js/product_discount_widget.js',
|
||||
'sale/static/src/js/product_discount_field.js',
|
||||
'sale/static/src/js/sale_product_field.js',
|
||||
'sale/static/src/xml/**/*',
|
||||
],
|
||||
'web.assets_frontend': [
|
||||
@@ -84,7 +81,6 @@ This module contains all the common features of Sales Management and eCommerce.
|
||||
'sale/static/tests/tours/**/*',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'sale/static/tests/product_configurator_tests.js',
|
||||
'sale/static/tests/sales_team_dashboard_tests.js',
|
||||
],
|
||||
'web.report_assets_common': [
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
odoo.define('sale.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 sale.product_configurator widget is a simple widget extending FieldMany2One
|
||||
* It allows the development of configuration strategies in other modules through
|
||||
* widget extensions.
|
||||
*
|
||||
*
|
||||
* !!! WARNING !!!
|
||||
*
|
||||
* This widget is only designed for sale_order_line creation/updates.
|
||||
* !!! It should only be used on a product_product or product_template field !!!
|
||||
*/
|
||||
var ProductConfiguratorWidget = relationalFields.FieldMany2One.extend({
|
||||
events: _.extend({}, relationalFields.FieldMany2One.prototype.events, {
|
||||
'click .o_edit_product_configuration': '_onEditConfiguration'
|
||||
}),
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_render: function () {
|
||||
this._super.apply(this, arguments);
|
||||
if (this.mode === 'edit' && this.value &&
|
||||
(this._isConfigurableProduct() || this._isConfigurableLine())) {
|
||||
this._addProductLinkButton();
|
||||
this._addConfigurationEditButton();
|
||||
} else if (this.mode === 'edit' && this.value) {
|
||||
this._addProductLinkButton();
|
||||
this.$('.o_edit_product_configuration').hide();
|
||||
} else {
|
||||
this.$('.o_external_button').hide();
|
||||
this.$('.o_edit_product_configuration').hide();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add button linking to product_id/product_template_id form.
|
||||
*/
|
||||
_addProductLinkButton: function () {
|
||||
if (this.$('.o_external_button').length === 0) {
|
||||
var $productLinkButton = $('<button>', {
|
||||
type: 'button',
|
||||
class: 'fa fa-external-link btn btn-secondary o_external_button',
|
||||
tabindex: '-1',
|
||||
draggable: false,
|
||||
'aria-label': _t('External Link'),
|
||||
title: _t('External Link')
|
||||
});
|
||||
|
||||
var $inputDropdown = this.$('.o_input_dropdown');
|
||||
$inputDropdown.after($productLinkButton);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* If current product is configurable,
|
||||
* Show edit button (in Edit Mode) after the product/product_template
|
||||
*/
|
||||
_addConfigurationEditButton: function () {
|
||||
var $inputDropdown = this.$('.o_input_dropdown');
|
||||
|
||||
if ($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);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook to override with _onEditProductConfiguration
|
||||
* to know if edit pencil button has to be put next to the field
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_isConfigurableProduct: function () {
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook to override with _onEditProductConfiguration
|
||||
* to know if edit pencil button has to be put next to the field
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_isConfigurableLine: function () {
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Override catching changes on product_id or product_template_id.
|
||||
* Calls _onTemplateChange in case of product_template change.
|
||||
* Calls _onProductChange in case of product change.
|
||||
* Shouldn't be overridden by product configurators
|
||||
* or only to setup some data for further computation
|
||||
* before calling super.
|
||||
*
|
||||
* @override
|
||||
* @param {OdooEvent} ev
|
||||
* @param {boolean} ev.data.preventProductIdCheck prevent the product configurator widget
|
||||
* from looping forever when it needs to change the 'product_template_id'
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
reset: async function (record, ev) {
|
||||
await this._super(...arguments);
|
||||
if (ev && ev.target === this) {
|
||||
if (ev.data.changes && !ev.data.preventProductIdCheck && ev.data.changes.product_template_id) {
|
||||
this._onTemplateChange(record.data.product_template_id.data.id, ev.data.dataPointID);
|
||||
} else if (ev.data.changes && ev.data.changes.product_id) {
|
||||
this._onProductChange(record.data.product_id.data && record.data.product_id.data.id, ev.data.dataPointID).then(wizardOpened => {
|
||||
if (!wizardOpened) {
|
||||
this._onLineConfigured();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook for product_template based configurators
|
||||
* (product configurator, matrix, ...).
|
||||
*
|
||||
* @param {integer} productTemplateId
|
||||
* @param {String} dataPointID
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onTemplateChange: function (productTemplateId, dataPointId) {
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook for product_product based configurators
|
||||
* (event, rental, ...).
|
||||
* Should return
|
||||
* true if product has been configured through wizard or
|
||||
* the result of the super call for other wizard extensions
|
||||
* false if the product wasn't configurable through the wizard
|
||||
*
|
||||
* @param {integer} productId
|
||||
* @param {String} dataPointID
|
||||
* @returns {Promise<Boolean>} stopPropagation true if a suitable configurator has been found.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onProductChange: function (productId, dataPointId) {
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook for configurator happening after line has been set
|
||||
* (options, ...).
|
||||
* Allows sale_product_configurator module to apply its options
|
||||
* after line configuration has been done.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onLineConfigured: function () {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggered on click of the configuration button.
|
||||
* It is only shown in Edit mode,
|
||||
* when _isConfigurableProduct or _isConfigurableLine is True.
|
||||
*
|
||||
* After reflexion, when a line was configured through two wizards,
|
||||
* only the line configuration will open.
|
||||
*
|
||||
* Two hooks are available depending on configurator category:
|
||||
* _onEditLineConfiguration : line configurators
|
||||
* _onEditProductConfiguration : product configurators
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEditConfiguration: function () {
|
||||
if (this._isConfigurableLine()) {
|
||||
this._onEditLineConfiguration();
|
||||
} else if (this._isConfigurableProduct()) {
|
||||
this._onEditProductConfiguration();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook for line configurators (rental, event)
|
||||
* on line edition (pencil icon inside product field)
|
||||
*/
|
||||
_onEditLineConfiguration: function () {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook for product configurators (matrix, product)
|
||||
* on line edition (pencil icon inside product field)
|
||||
*/
|
||||
_onEditProductConfiguration: function () {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Utilities for recordData conversion
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 (resId) {
|
||||
convertedValues.push([4, parseInt(resId)]);
|
||||
});
|
||||
|
||||
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 (resId) {
|
||||
if (isNaN(resId)) {
|
||||
_.each(recordData.data, function (record) {
|
||||
if (record.ref === resId) {
|
||||
convertedValues.push([0, 0, {
|
||||
custom_product_template_attribute_value_id: record.data.custom_product_template_attribute_value_id.data.id,
|
||||
custom_value: record.data.custom_value
|
||||
}]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
convertedValues.push([4, resId]);
|
||||
}
|
||||
});
|
||||
|
||||
return convertedValues;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
FieldsRegistry.add('product_configurator', ProductConfiguratorWidget);
|
||||
|
||||
return ProductConfiguratorWidget;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { FloatField } from "@web/views/fields/float/float_field";
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
|
||||
/**
|
||||
* Dialog called if user changes a value in the sale order line.
|
||||
* The wizard will open only if
|
||||
* (1) Sale order line is 3 or more
|
||||
* (2) First sale order line is changed
|
||||
* (3) value is the same in all other sale order line
|
||||
*/
|
||||
|
||||
export class ProductDiscountField extends FloatField {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dialogService = useService("dialog");
|
||||
}
|
||||
|
||||
onChange(ev) {
|
||||
const x2mList = this.props.record.model.root.data.order_line;
|
||||
const orderLines = x2mList.records.filter(line => !line.data.display_type);
|
||||
|
||||
if (orderLines.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstOrderLine = this.props.record.data.id === orderLines[0].data.id;
|
||||
if (isFirstOrderLine && sameValue(orderLines)) {
|
||||
this.dialogService.add(ConfirmationDialog, {
|
||||
body: _lt("Do you want to apply this value to all lines ?"),
|
||||
confirm: () => {
|
||||
const commands = orderLines.slice(1).map((line) => {
|
||||
return {
|
||||
operation: "UPDATE",
|
||||
record: line,
|
||||
data: {["discount"]: Number(ev.target.value)},
|
||||
};
|
||||
});
|
||||
|
||||
x2mList.applyCommands('order_line', commands);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO remove this function, no need to export it anymore.
|
||||
export function sameValue(orderLines) {
|
||||
const compareValue = orderLines[1].data.discount;
|
||||
return orderLines.slice(1).every(line => line.data.discount === compareValue);
|
||||
}
|
||||
|
||||
|
||||
ProductDiscountField.components = { ConfirmationDialog };
|
||||
ProductDiscountField.template = "sale.ProductDiscountField";
|
||||
ProductDiscountField.displayName = _lt("Disc.%");
|
||||
|
||||
registry.category("fields").add("sol_discount", ProductDiscountField)
|
||||
@@ -1,26 +0,0 @@
|
||||
odoo.define('sale.product_discount', function (require) {
|
||||
"use strict";
|
||||
|
||||
const BasicFields = require('web.basic_fields');
|
||||
const FieldsRegistry = require('web.field_registry');
|
||||
const UpdateAllLinesMixin = require('sale.UpdateAllLinesMixin');
|
||||
|
||||
/**
|
||||
* The sale.product_discount widget is a simple widget extending FieldFloat
|
||||
*
|
||||
*
|
||||
* !!! WARNING !!!
|
||||
*
|
||||
* This widget is only designed for sale_order_line creation/updates.
|
||||
*/
|
||||
const ProductDiscountWidget = BasicFields.FieldFloat.extend(UpdateAllLinesMixin, {
|
||||
_getUpdateAllLinesAction: function () {
|
||||
return 'open_update_all_wizard';
|
||||
},
|
||||
});
|
||||
|
||||
FieldsRegistry.add('product_discount', ProductDiscountWidget);
|
||||
|
||||
return ProductDiscountWidget;
|
||||
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
odoo.define('sale.SaleOrderFormController', function (require) {
|
||||
"use strict";
|
||||
|
||||
const FormController = require('web.FormController');
|
||||
const Dialog = require('web.Dialog');
|
||||
const core = require('web.core');
|
||||
const _t = core._t;
|
||||
|
||||
const SaleOrderFormController = FormController.extend({
|
||||
custom_events: _.extend({}, FormController.prototype.custom_events, {
|
||||
open_update_all_wizard: '_onOpenUpdateAllWizard',
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handler called if user changes a value in the sale order line.
|
||||
* The wizard will open only if
|
||||
* (1) Sale order line is 3 or more
|
||||
* (2) First sale order line is changed
|
||||
* (3) value is the same in all other sale order line
|
||||
*/
|
||||
_onOpenUpdateAllWizard(ev) {
|
||||
const orderLines = this._DialogReady(ev, 'normal');
|
||||
const customValuesCommands = [];
|
||||
const confirmCallback = () => {
|
||||
orderLines.slice(1).forEach((line) => {
|
||||
customValuesCommands.push({
|
||||
operation: "UPDATE",
|
||||
id: line.id,
|
||||
data: {[ev.data.fieldName]: ev.data.value},
|
||||
});
|
||||
});
|
||||
this.trigger_up('field_changed', {
|
||||
dataPointID: this.renderer.state.id,
|
||||
changes: {order_line: {operation: "MULTI", commands: customValuesCommands}},
|
||||
});
|
||||
};
|
||||
if (orderLines) {
|
||||
Dialog.confirm(this, _t("Do you want to apply this value to all order lines?"), {
|
||||
buttons: [{
|
||||
text: _t('YES'),
|
||||
classes: 'btn-primary',
|
||||
close: true,
|
||||
click: () => confirmCallback(),
|
||||
}, {
|
||||
text: _t("NO"),
|
||||
close: true,
|
||||
}],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_isEqualValue (type, fieldName, orderLines) {
|
||||
let isEqualValue;
|
||||
let secondValue = orderLines[1].data[fieldName];
|
||||
if (type === 'normal') {
|
||||
if (secondValue instanceof moment) {
|
||||
isEqualValue = orderLines.slice(1).every(line => secondValue.isSame(line.data[fieldName]));
|
||||
} else {
|
||||
isEqualValue = orderLines.slice(1).every(line => line.data[fieldName] === secondValue);
|
||||
}
|
||||
} else if (type === 'one2many') {
|
||||
// only works for display_name because we want to be able to apply different id with similar properties
|
||||
if (secondValue.data && secondValue.data.display_name) {
|
||||
secondValue = secondValue.data.display_name;
|
||||
isEqualValue = orderLines.slice(1).every(line => line.data[fieldName].data && line.data[fieldName].data.display_name === secondValue);
|
||||
} else {
|
||||
isEqualValue = orderLines.slice(1).every(line => line.data[fieldName] === secondValue);
|
||||
}
|
||||
}
|
||||
return isEqualValue;
|
||||
},
|
||||
|
||||
_DialogReady (ev, type) {
|
||||
const recordData = ev.target.recordData;
|
||||
const fieldName = ev.data.fieldName;
|
||||
const orderLines = this.renderer.state.data.order_line.data.filter(line => !line.data.display_type);
|
||||
if (orderLines.length < 3) {
|
||||
return false;
|
||||
}
|
||||
if (recordData.id === orderLines[0].data.id && this._isEqualValue(type, fieldName, orderLines)) {
|
||||
return orderLines;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return SaleOrderFormController;
|
||||
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
odoo.define('sale.UpdateAllLinesMixin', function (require) {
|
||||
'use strict';
|
||||
|
||||
const UpdateAllLinesMixin = {
|
||||
_onFieldChanged: function (ev) {
|
||||
if (ev && ev.data.changes) {
|
||||
const fieldName = Object.keys(ev.data.changes)[0];
|
||||
const value = ev.data.changes[fieldName];
|
||||
this.trigger_up(this._getUpdateAllLinesAction(), {fieldName: fieldName, value: value});
|
||||
}
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
_getUpdateAllLinesAction: function () {
|
||||
return '';
|
||||
},
|
||||
};
|
||||
return UpdateAllLinesMixin;
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
odoo.define('sale.SaleOrderView', function (require) {
|
||||
"use strict";
|
||||
|
||||
const SaleOrderFormController = require('sale.SaleOrderFormController');
|
||||
const FormView = require('web.FormView');
|
||||
const viewRegistry = require('web.view_registry');
|
||||
|
||||
const SaleOrderView = FormView.extend({
|
||||
config: _.extend({}, FormView.prototype.config, {
|
||||
Controller: SaleOrderFormController,
|
||||
}),
|
||||
});
|
||||
|
||||
viewRegistry.add('sale_discount_form', SaleOrderView);
|
||||
|
||||
return SaleOrderView;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { Many2OneField } from '@web/views/fields/many2one/many2one_field';
|
||||
|
||||
export class SaleOrderLineProductField extends Many2OneField {
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
const { update } = this;
|
||||
this.update = async (value) => {
|
||||
await update(value);
|
||||
let newValue = false;
|
||||
// NB: quick creation doesn't go through here, but through quickCreate
|
||||
// below
|
||||
if (value) {
|
||||
if (Array.isArray(value[0]) && this.props.value != value[0]) {
|
||||
// product (existing)
|
||||
newValue = true;
|
||||
} else {
|
||||
// new product (Create & edit)
|
||||
// value[0] is a dict of creation values
|
||||
newValue = true;
|
||||
}
|
||||
}
|
||||
if (newValue) {
|
||||
if (this.props.relation === 'product.template') {
|
||||
this._onProductTemplateUpdate();
|
||||
} else {
|
||||
this._onProductUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (this.props.canQuickCreate) {
|
||||
// HACK to make quick creation also open
|
||||
// configurators if needed
|
||||
this.quickCreate = async (name, params = {}) => {
|
||||
await this.props.record.update({ [this.props.name]: [false, name]});
|
||||
|
||||
if (this.props.relation === 'product.template') {
|
||||
this._onProductTemplateUpdate();
|
||||
} else {
|
||||
this._onProductUpdate();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
get hasConfigurationButton() {
|
||||
return this.isConfigurableLine || this.isConfigurableTemplate;
|
||||
}
|
||||
get isConfigurableLine() { return false; }
|
||||
get isConfigurableTemplate() { return false; }
|
||||
|
||||
get configurationButtonHelp() {
|
||||
return this.env._t("Edit Configuration");
|
||||
}
|
||||
|
||||
get configurationButtonIcon() {
|
||||
return 'btn btn-secondary fa ' + this.configurationButtonFAIcon();
|
||||
}
|
||||
|
||||
configurationButtonFAIcon() {
|
||||
return 'fa-pencil';
|
||||
}
|
||||
|
||||
async _onProductTemplateUpdate() { }
|
||||
async _onProductUpdate() { } // event_booth_sale, event_sale, sale_renting
|
||||
|
||||
onEditConfiguration() {
|
||||
if (this.isConfigurableLine) {
|
||||
this._editLineConfiguration();
|
||||
} else {
|
||||
this._editProductConfiguration();
|
||||
}
|
||||
}
|
||||
_editLineConfiguration() { } // event_booth_sale, event_sale, sale_renting
|
||||
_editProductConfiguration() { } // sale_product_configurator, sale_product_matrix
|
||||
|
||||
}
|
||||
|
||||
SaleOrderLineProductField.template = "sale.SaleProductField";
|
||||
|
||||
registry.category("fields").add("sol_product_many2one", SaleOrderLineProductField);
|
||||
@@ -326,8 +326,11 @@ var VariantMixin = {
|
||||
var route = '/sale/create_product_variant';
|
||||
if (useAjax) {
|
||||
productReady = ajax.jsonRpc(route, 'call', params);
|
||||
} else {
|
||||
} else if (Boolean(this._rpc)) {
|
||||
// HACK to combine owl and non owl calls
|
||||
productReady = this._rpc({route: route, params: params});
|
||||
} else {
|
||||
productReady = this.rpc(route, params);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -292,6 +292,7 @@ label.css_attribute_color.css_not_available {
|
||||
|
||||
.o_list_view {
|
||||
.o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier) {
|
||||
// FIXME o_edit_product_configuration no longer exists
|
||||
.o_field_widget .o_edit_product_configuration {
|
||||
padding: 0;
|
||||
background-color: inherit;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.ProductDiscountField" t-inherit="web.FloatField" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//input" position="attributes">
|
||||
<attribute name="t-on-change">onChange</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="sale.SaleProductField" t-inherit="web.Many2OneField" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//t[@t-if='hasExternalButton']" position="before">
|
||||
<t t-if="hasConfigurationButton">
|
||||
<button
|
||||
type="button"
|
||||
t-att-class="configurationButtonIcon"
|
||||
tabindex="-1"
|
||||
draggable="false"
|
||||
t-att-aria-label="configurationButtonHelp"
|
||||
t-att-data-tooltip="configurationButtonHelp"
|
||||
t-on-click="onEditConfiguration"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -7,6 +7,7 @@
|
||||
<t t-call="web.ProgressBarField"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<!-- TODO is this class needed here ? -->
|
||||
<a t-on-click.prevent="defineInvoicingTarget" href="#" class="sale_progressbar_form_link">
|
||||
Click to define an invoicing target
|
||||
</a>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
odoo.define('sale.product_configurator_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const FormView = require('web.FormView');
|
||||
const testUtils = require('web.test_utils');
|
||||
|
||||
const createView = testUtils.createView;
|
||||
|
||||
QUnit.module('product_configurator', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
order: {
|
||||
fields: {
|
||||
line_ids: {
|
||||
string: "Lines",
|
||||
type: 'one2many',
|
||||
relation: 'line',
|
||||
relation_field: 'order_id'
|
||||
},
|
||||
},
|
||||
records: [
|
||||
{id: 1, line_ids: [1, 2]},
|
||||
],
|
||||
},
|
||||
line: {
|
||||
fields: {
|
||||
product_id: {
|
||||
string: "Product",
|
||||
type: 'many2one',
|
||||
relation: 'product',
|
||||
},
|
||||
order_id: {
|
||||
string: "Order",
|
||||
type: 'many2one',
|
||||
relation: 'order'
|
||||
},
|
||||
sequence: {
|
||||
string: "Sequence",
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
records: [
|
||||
{id: 1, sequence: 4, product_id: 3, order_id: 1},
|
||||
{id: 2, sequence: 14, product_id: 4, order_id: 1},
|
||||
]
|
||||
},
|
||||
product: {
|
||||
fields: {
|
||||
name: {
|
||||
string: "Name",
|
||||
type: 'char',
|
||||
},
|
||||
},
|
||||
records: [
|
||||
{id: 3, name: "Chair"},
|
||||
{id: 4, name: "Table"},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
}, function () {
|
||||
QUnit.test('drag and drop rows containing product_configurator many2one', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'order',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="line_ids"/>
|
||||
</form>`,
|
||||
archs: {
|
||||
'line,false,list': `
|
||||
<tree editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="product_id" widget="product_configurator"/>
|
||||
</tree>`,
|
||||
},
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsN(form, '.o_data_row', 2);
|
||||
assert.strictEqual(form.$('.o_data_row').text(), 'ChairTable');
|
||||
assert.containsN(form, '.o_data_row .o_row_handle', 2);
|
||||
|
||||
// move first row below second
|
||||
const $firstHandle = form.$('.o_data_row:nth(0) .o_row_handle');
|
||||
const $secondHandle = form.$('.o_data_row:nth(1) .o_row_handle');
|
||||
await testUtils.dom.dragAndDrop($firstHandle, $secondHandle, { position: 'bottom' });
|
||||
|
||||
assert.strictEqual(form.$('.o_data_row').text(), 'TableChair');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -185,7 +185,7 @@
|
||||
<field name="name">sale.order.form</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Sales Order" class="o_sale_order" js_class="sale_discount_form">
|
||||
<form string="Sales Order" class="o_sale_order">
|
||||
<header>
|
||||
<field name="authorized_transaction_ids" invisible="1"/>
|
||||
<button name="payment_action_capture" type="object" data-hotkey="shift+g"
|
||||
@@ -309,6 +309,7 @@
|
||||
force_save="1"
|
||||
widget="many2one_barcode"
|
||||
/>
|
||||
<field name="product_type" invisible="1"/>
|
||||
<field name="invoice_status" invisible="1"/>
|
||||
<field name="qty_to_invoice" invisible="1"/>
|
||||
<field name="qty_delivered_method" invisible="1"/>
|
||||
@@ -395,7 +396,7 @@
|
||||
<!-- We do not display the type because we don't want the user to be bothered with that information if he has no section or note. -->
|
||||
<field name="display_type" invisible="1"/>
|
||||
<field name="product_uom_category_id" invisible="1"/>
|
||||
|
||||
<field name="product_type" invisible="1"/>
|
||||
<field name="product_updatable" invisible="1"/>
|
||||
<field
|
||||
name="product_id"
|
||||
@@ -414,7 +415,7 @@
|
||||
'default_description_sale': name
|
||||
}"
|
||||
domain="[('sale_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', parent.company_id)]"
|
||||
widget="product_configurator"
|
||||
widget="sol_product_many2one"
|
||||
/>
|
||||
<field name="product_template_id"
|
||||
string="Product"
|
||||
@@ -433,7 +434,7 @@
|
||||
'default_description_sale': name
|
||||
}"
|
||||
domain="[('sale_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', parent.company_id)]"
|
||||
widget="product_configurator"/>
|
||||
widget="sol_product_many2one"/>
|
||||
<field name="name" widget="section_and_note_text" optional="show"/>
|
||||
<field name="analytic_distribution_stored_char" invisible="1"/>
|
||||
<!-- TODO widget="analytic_distribution" -->
|
||||
@@ -507,7 +508,7 @@
|
||||
attrs="{'readonly': [('qty_invoiced', '>', 0)]}"
|
||||
optional="show"
|
||||
/>
|
||||
<field name="discount" string="Disc.%" groups="product.group_discount_per_so_line" optional="show" widget="product_discount"/>
|
||||
<field name="discount" string="Disc.%" groups="product.group_discount_per_so_line" optional="show" widget="sol_discount"/>
|
||||
<field name="is_downpayment" invisible="1"/>
|
||||
<field name="price_subtotal" widget="monetary" groups="account.group_show_line_subtotals_tax_excluded" attrs="{'invisible': [('is_downpayment', '=', True)]}"/>
|
||||
<field name="price_total" widget="monetary" groups="account.group_show_line_subtotals_tax_included" attrs="{'invisible': [('is_downpayment', '=', True)]}"/>
|
||||
|
||||
@@ -25,11 +25,8 @@ It also enables the "optional products" feature.
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'sale/static/src/js/variant_mixin.js',
|
||||
'sale_product_configurator/static/src/js/product_configurator_widget.js',
|
||||
'sale_product_configurator/static/src/js/product_configurator_modal.js',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'sale_product_configurator/static/tests/product_configurator.test.js',
|
||||
'sale_product_configurator/static/src/js/sale_product_field.js',
|
||||
],
|
||||
},
|
||||
'auto_install': True,
|
||||
|
||||
@@ -21,12 +21,15 @@ class ProductConfiguratorController(http.Controller):
|
||||
if pricelist:
|
||||
product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id)
|
||||
|
||||
return request.env['ir.ui.view']._render_template("sale_product_configurator.configure", {
|
||||
'product': product_template,
|
||||
'pricelist': pricelist,
|
||||
'add_qty': add_qty,
|
||||
'product_combination': product_combination
|
||||
})
|
||||
return request.env['ir.ui.view']._render_template(
|
||||
"sale_product_configurator.configure",
|
||||
{
|
||||
'product': product_template,
|
||||
'pricelist': pricelist,
|
||||
'add_qty': add_qty,
|
||||
'product_combination': product_combination
|
||||
},
|
||||
)
|
||||
|
||||
@http.route(['/sale_product_configurator/show_advanced_configurator'], type='json', auth="user", methods=['POST'])
|
||||
def show_advanced_configurator(self, product_id, variant_values, pricelist_id, **kw):
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
/** @odoo-module */
|
||||
import ProductConfiguratorWidget from "sale.product_configurator";
|
||||
import { OptionalProductsModal } from "@sale_product_configurator/js/product_configurator_modal";
|
||||
import {
|
||||
selectOrCreateProduct,
|
||||
getSelectedVariantValues,
|
||||
getNoVariantAttributeValues,
|
||||
} from "sale.VariantMixin";
|
||||
import { _t } from "web.core";
|
||||
|
||||
/**
|
||||
* Extension of the ProductConfiguratorWidget to support product configuration.
|
||||
* It opens when a configurable product_template is set.
|
||||
* (multiple variants, or custom attributes)
|
||||
*
|
||||
* The product customization information includes :
|
||||
* - is_configurable_product
|
||||
* - product_template_attribute_value_ids
|
||||
*
|
||||
*/
|
||||
ProductConfiguratorWidget.include({
|
||||
/**
|
||||
* Override of sale.product_configurator Hook
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
_isConfigurableProduct: function () {
|
||||
return this.recordData.is_configurable_product || this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set restoreProductTemplateId for further backtrack.
|
||||
* Saves the optional products in the widget for future application
|
||||
* post-line configuration.
|
||||
*
|
||||
* {OdooEvent ev}
|
||||
* {Array} ev.data.optionalProducts the various selected optional products
|
||||
* with their configuration
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
reset: function (record, ev) {
|
||||
if (ev && ev.target === this) {
|
||||
this.restoreProductTemplateId = this.recordData.product_template_id;
|
||||
this.optionalProducts = (ev.data && ev.data.optionalProducts) || this.optionalProducts;
|
||||
}
|
||||
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
/**
|
||||
* This method is overridden to check if the product_template_id
|
||||
* needs configuration or not:
|
||||
*
|
||||
* - The product_template has only one "product.product" and is not dynamic
|
||||
* -> Set the product_id on the SO line
|
||||
* -> If the product has optional products, open the configurator in 'options' mode
|
||||
*
|
||||
* - The product_template is configurable
|
||||
* -> Open the product configurator wizard and initialize it with
|
||||
* the provided product_template_id and its current attribute values
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onTemplateChange: function (productTemplateId, dataPointId) {
|
||||
var self = this;
|
||||
var ctx = {};
|
||||
if (this.record && this.recordParams) {
|
||||
ctx = this.record.getContext(this.recordParams);
|
||||
}
|
||||
|
||||
return this._rpc({
|
||||
model: "product.template",
|
||||
method: "get_single_product_variant",
|
||||
args: [productTemplateId],
|
||||
context: ctx,
|
||||
}).then(function (result) {
|
||||
if (result.product_id && !result.has_optional_products) {
|
||||
self.trigger_up("field_changed", {
|
||||
dataPointID: dataPointId,
|
||||
changes: {
|
||||
product_id: { id: result.product_id },
|
||||
product_custom_attribute_value_ids: {
|
||||
operation: "DELETE_ALL",
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return self._openConfigurator(result, productTemplateId, dataPointId);
|
||||
}
|
||||
// always returns true for the moment because no other configurator exists.
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* When line is configured, apply the options defined earlier.
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onLineConfigured: function () {
|
||||
var self = this;
|
||||
this._super.apply(this, arguments);
|
||||
var parentList = self.getParent();
|
||||
var unselectRow = (parentList.unselectRow || function () {}).bind(parentList); // form view on mobile
|
||||
if (self.optionalProducts && self.optionalProducts.length !== 0) {
|
||||
self.trigger_up("add_record", {
|
||||
context: self._productsToRecords(self.optionalProducts),
|
||||
forceEditable: "bottom",
|
||||
allowWarning: true,
|
||||
onSuccess: function () {
|
||||
// Leave edit mode of one2many list.
|
||||
unselectRow();
|
||||
},
|
||||
});
|
||||
} else if (!self._isConfigurableLine() && self._isConfigurableProduct()) {
|
||||
// Leave edit mode of current line if line was configured
|
||||
// only through the product configurator.
|
||||
unselectRow();
|
||||
}
|
||||
},
|
||||
|
||||
_openConfigurator: function (result, productTemplateId, dataPointId) {
|
||||
if (!result.mode || result.mode === "configurator") {
|
||||
this._openProductConfigurator(
|
||||
{
|
||||
configuratorMode: result && result.has_optional_products ? "options" : "add",
|
||||
pricelist_id: this._getPricelistId(),
|
||||
product_template_id: productTemplateId,
|
||||
},
|
||||
dataPointId
|
||||
);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the product configurator to allow configuring the product template
|
||||
* and its various options.
|
||||
*
|
||||
* The configuratorMode param controls how to open the configurator.
|
||||
* - The "add" mode will allow configuring the product template & options.
|
||||
* - The "edit" mode will only allow editing the product template's configuration.
|
||||
* - The "options" mode is a special case where the product configurator is used as a bridge
|
||||
* between the SO line and the optional products modal. It will hide its window and handle
|
||||
* the communication between those two.
|
||||
*
|
||||
* When the configuration is canceled (i.e when the product configurator is closed using the
|
||||
* "CANCEL" button or the cross on the top right corner of the window),
|
||||
* the product_template is reset to its previous value if any.
|
||||
*
|
||||
* @param {Object} data various "default_" values
|
||||
* {string} data.configuratorMode 'add' or 'edit' or 'options'.
|
||||
* @param {string} dataPointId
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _openProductConfigurator(data, dataPointId) {
|
||||
this.optionalProducts = undefined;
|
||||
const optionalProductsModal = await this._openModal(data);
|
||||
// no optional products found for this product, only add the root product
|
||||
optionalProductsModal.on("options_empty", null, () =>
|
||||
this._addProducts({ mainProduct: this.rootProduct }, dataPointId)
|
||||
);
|
||||
|
||||
let confirmed = false;
|
||||
optionalProductsModal.on("confirm", null, async () => {
|
||||
confirmed = true;
|
||||
const [
|
||||
mainProduct,
|
||||
...options
|
||||
] = await optionalProductsModal.getAndCreateSelectedProducts();
|
||||
this._addProducts({ mainProduct, options }, dataPointId);
|
||||
});
|
||||
optionalProductsModal.on("closed", null, () => {
|
||||
if (confirmed) {
|
||||
return;
|
||||
}
|
||||
if (this.restoreProductTemplateId) {
|
||||
// if configurator opened in edit mode.
|
||||
this.trigger_up("field_changed", {
|
||||
dataPointID: dataPointId,
|
||||
preventProductIdCheck: true,
|
||||
changes: {
|
||||
product_template_id: this.restoreProductTemplateId.data,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// if configurator opened to create line:
|
||||
// destroy line if configurator closed during configuration process.
|
||||
this.trigger_up("field_changed", {
|
||||
dataPointID: dataPointId,
|
||||
changes: {
|
||||
product_template_id: false,
|
||||
product_id: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async _openModal(data) {
|
||||
const {
|
||||
product_template_id,
|
||||
product_template_attribute_value_ids,
|
||||
product_no_variant_attribute_value_ids,
|
||||
pricelist_id,
|
||||
product_custom_attribute_value_ids,
|
||||
quantity,
|
||||
configuratorMode,
|
||||
} = data;
|
||||
// FIXME replace this with an RPC instead of rendering a template and querying it for data
|
||||
const $modal = $(
|
||||
await this._rpc({
|
||||
route: "/sale_product_configurator/configure",
|
||||
params: {
|
||||
product_template_id,
|
||||
pricelist_id,
|
||||
add_qty: quantity,
|
||||
product_template_attribute_value_ids,
|
||||
product_no_variant_attribute_value_ids,
|
||||
context: this.getSession().user_context,
|
||||
},
|
||||
})
|
||||
);
|
||||
const productSelector = `input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked`;
|
||||
const productTemplateId = $modal.find(".product_template_id").val();
|
||||
const productId = await selectOrCreateProduct.call(
|
||||
this,
|
||||
$modal,
|
||||
parseInt($modal.find(productSelector).first().val(), 10),
|
||||
productTemplateId,
|
||||
false
|
||||
);
|
||||
$modal.find(productSelector).val(productId);
|
||||
const variantValues = getSelectedVariantValues($modal);
|
||||
const noVariantAttributeValues = getNoVariantAttributeValues($modal);
|
||||
this.rootProduct = {
|
||||
product_id: productId,
|
||||
product_template_id: parseInt(productTemplateId),
|
||||
quantity: parseFloat($modal.find('input[name="add_qty"]').val() || 1),
|
||||
variant_values: variantValues,
|
||||
product_custom_attribute_values: product_custom_attribute_value_ids,
|
||||
no_variant_attribute_values: noVariantAttributeValues,
|
||||
};
|
||||
return new OptionalProductsModal(null, {
|
||||
rootProduct: this.rootProduct,
|
||||
pricelistId: pricelist_id,
|
||||
okButtonText: _t("Confirm"),
|
||||
cancelButtonText: _t("Back"),
|
||||
title: _t("Configure"),
|
||||
context:
|
||||
(this.record && this.recordParams && this.record.getContext(this.recordParams)) ||
|
||||
{},
|
||||
previousModalHeight: this.$el.closest(".modal-content").height(),
|
||||
mode: configuratorMode,
|
||||
}).open();
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the product configurator in "edit" mode.
|
||||
* (see '_openProductConfigurator' for more info on the "edit" mode).
|
||||
* The requires to retrieve all the needed data from the SO line
|
||||
* that are kept in the "recordData" object.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEditProductConfiguration: function () {
|
||||
if (!this.recordData.is_configurable_product) {
|
||||
// if line should be edited by another configurator
|
||||
// or simply inline.
|
||||
this._super.apply(this, arguments);
|
||||
return;
|
||||
}
|
||||
this.restoreProductTemplateId = this.recordData.product_template_id;
|
||||
// If line has been set up through the product_configurator:
|
||||
this._openProductConfigurator(
|
||||
{
|
||||
configuratorMode: "edit",
|
||||
product_template_id: this.recordData.product_template_id.data.id,
|
||||
pricelist_id: this._getPricelistId(),
|
||||
product_template_attribute_value_ids: this._toIdArray(
|
||||
this.recordData.product_template_attribute_value_ids
|
||||
),
|
||||
product_no_variant_attribute_value_ids: this._toIdArray(
|
||||
this.recordData.product_no_variant_attribute_value_ids
|
||||
),
|
||||
product_custom_attribute_value_ids: this.recordData.product_custom_attribute_value_ids.data.map(
|
||||
(d) => d.data
|
||||
),
|
||||
quantity: this.recordData.product_uom_qty,
|
||||
},
|
||||
this.dataPointID
|
||||
);
|
||||
},
|
||||
|
||||
_toIdArray(recordData) {
|
||||
return recordData ? [...recordData.res_ids] : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* This will first modify the SO line to update all the information coming from
|
||||
* the product configurator using the 'field_changed' event.
|
||||
*
|
||||
* onSuccess from that first method, it will add the optional products to the SO
|
||||
* using the 'add_record' event.
|
||||
*
|
||||
* Doing both at the same time could lead to unordered product_template/options.
|
||||
*
|
||||
* @param {Object} products the products to add to the SO line.
|
||||
* {Object} products.mainProduct the product_template configured
|
||||
* with various attribute/custom values
|
||||
* {Array} products.options the various selected optional products
|
||||
* with their configuration
|
||||
* @param {string} dataPointId
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_addProducts: function (result, dataPointId) {
|
||||
this.trigger_up("field_changed", {
|
||||
dataPointID: dataPointId,
|
||||
preventProductIdCheck: true,
|
||||
optionalProducts: result.options,
|
||||
changes: this._getMainProductChanges(result.mainProduct),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* This will convert the result of the product configurator into
|
||||
* "changes" that are understood by the basic_model.js
|
||||
*
|
||||
* For the product_custom_attribute_value_ids, we need to do a DELETE_ALL
|
||||
* command to clean the currently selected values and then a CREATE for every
|
||||
* custom value specified in the configurator.
|
||||
*
|
||||
* For the product_no_variant_attribute_value_ids, we also need to do a DELETE_ALL
|
||||
* command to clean the currently selected values and issue a single ADD_M2M containing
|
||||
* all the ids of the product_attribute_values.
|
||||
*
|
||||
* @param {Object} mainProduct
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_getMainProductChanges: function (mainProduct) {
|
||||
var result = {
|
||||
product_id: { id: mainProduct.product_id },
|
||||
product_template_id: { id: mainProduct.product_template_id },
|
||||
product_uom_qty: mainProduct.quantity,
|
||||
};
|
||||
|
||||
var customAttributeValues = mainProduct.product_custom_attribute_values;
|
||||
var customValuesCommands = [{ operation: "DELETE_ALL" }];
|
||||
if (customAttributeValues && customAttributeValues.length !== 0) {
|
||||
_.each(customAttributeValues, function (customValue) {
|
||||
// FIXME awa: This could be optimized by adding a "disableDefaultGet" to avoid
|
||||
// having multiple default_get calls that are useless since we already
|
||||
// have all the default values locally.
|
||||
// However, this would mean a lot of changes in basic_model.js to handle
|
||||
// those "default_" values and set them on the various fields (text,o2m,m2m,...).
|
||||
// -> This is not considered as worth it right now.
|
||||
customValuesCommands.push({
|
||||
operation: "CREATE",
|
||||
context: [
|
||||
{
|
||||
default_custom_product_template_attribute_value_id:
|
||||
customValue.custom_product_template_attribute_value_id,
|
||||
default_custom_value: customValue.custom_value,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
result["product_custom_attribute_value_ids"] = {
|
||||
operation: "MULTI",
|
||||
commands: customValuesCommands,
|
||||
};
|
||||
|
||||
var noVariantAttributeValues = mainProduct.no_variant_attribute_values;
|
||||
var noVariantCommands = [{ operation: "DELETE_ALL" }];
|
||||
if (noVariantAttributeValues && noVariantAttributeValues.length !== 0) {
|
||||
var resIds = _.map(noVariantAttributeValues, function (noVariantValue) {
|
||||
return { id: parseInt(noVariantValue.value) };
|
||||
});
|
||||
|
||||
noVariantCommands.push({
|
||||
operation: "ADD_M2M",
|
||||
ids: resIds,
|
||||
});
|
||||
}
|
||||
|
||||
result["product_no_variant_attribute_value_ids"] = {
|
||||
operation: "MULTI",
|
||||
commands: noVariantCommands,
|
||||
};
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the pricelist_id set on the sale_order form
|
||||
*
|
||||
* @private
|
||||
* @returns {integer} pricelist_id's id
|
||||
*/
|
||||
_getPricelistId: function () {
|
||||
return this.record.evalContext.parent.pricelist_id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Will map the products to appropriate record objects that are
|
||||
* ready for the default_get.
|
||||
*
|
||||
* @param {Array} products The products to transform into records
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_productsToRecords: function (products) {
|
||||
var records = [];
|
||||
_.each(products, function (product) {
|
||||
var record = {
|
||||
default_product_id: product.product_id,
|
||||
default_product_template_id: product.product_template_id,
|
||||
default_product_uom_qty: product.quantity,
|
||||
};
|
||||
|
||||
if (product.no_variant_attribute_values) {
|
||||
var defaultProductNoVariantAttributeValues = [];
|
||||
_.each(product.no_variant_attribute_values, function (attributeValue) {
|
||||
defaultProductNoVariantAttributeValues.push([
|
||||
4,
|
||||
parseInt(attributeValue.value),
|
||||
]);
|
||||
});
|
||||
record[
|
||||
"default_product_no_variant_attribute_value_ids"
|
||||
] = defaultProductNoVariantAttributeValues;
|
||||
}
|
||||
|
||||
if (product.product_custom_attribute_values) {
|
||||
var defaultCustomAttributeValues = [];
|
||||
_.each(product.product_custom_attribute_values, function (attributeValue) {
|
||||
defaultCustomAttributeValues.push([
|
||||
0,
|
||||
0,
|
||||
{
|
||||
custom_product_template_attribute_value_id:
|
||||
attributeValue.custom_product_template_attribute_value_id,
|
||||
custom_value: attributeValue.custom_value,
|
||||
},
|
||||
]);
|
||||
});
|
||||
record["default_product_custom_attribute_value_ids"] = defaultCustomAttributeValues;
|
||||
}
|
||||
|
||||
records.push(record);
|
||||
});
|
||||
|
||||
return records;
|
||||
},
|
||||
});
|
||||
|
||||
export { ProductConfiguratorWidget };
|
||||
@@ -0,0 +1,260 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { SaleOrderLineProductField } from '@sale/js/sale_product_field';
|
||||
import { OptionalProductsModal } from "@sale_product_configurator/js/product_configurator_modal";
|
||||
import {
|
||||
selectOrCreateProduct,
|
||||
getSelectedVariantValues,
|
||||
getNoVariantAttributeValues,
|
||||
} from "sale.VariantMixin";
|
||||
|
||||
|
||||
patch(SaleOrderLineProductField.prototype, 'sale_product_configurator', {
|
||||
|
||||
setup() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.rpc = useService("rpc");
|
||||
this.ui = useService("ui");
|
||||
},
|
||||
|
||||
async _onProductTemplateUpdate() {
|
||||
this._super(...arguments);
|
||||
const result = await this.orm.call(
|
||||
'product.template',
|
||||
'get_single_product_variant',
|
||||
[this.props.record.data.product_template_id[0]],
|
||||
);
|
||||
if(result && result.product_id) {
|
||||
if (this.props.record.data.product_id != result.product_id.id) {
|
||||
await this.props.record.update({
|
||||
product_id: [result.product_id, 'whatever'],
|
||||
});
|
||||
if (result.has_optional_products) {
|
||||
this._openProductConfigurator('options');
|
||||
} else {
|
||||
this._onProductUpdate();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!result.mode || result.mode === 'configurator') {
|
||||
this._openProductConfigurator('add');
|
||||
} else {
|
||||
// only triggered when sale_product_matrix is installed.
|
||||
this._openGridConfigurator(result.mode);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_editProductConfiguration() {
|
||||
this._super(...arguments);
|
||||
if (this.props.record.data.is_configurable_product) {
|
||||
this._openProductConfigurator('edit');
|
||||
}
|
||||
},
|
||||
|
||||
get isConfigurableTemplate() {
|
||||
return this._super(...arguments) || this.props.record.data.is_configurable_product;
|
||||
},
|
||||
|
||||
async _openProductConfigurator(mode) {
|
||||
const saleOrderRecord = this.props.record.model.root;
|
||||
const pricelistId = saleOrderRecord.data.pricelist_id ? saleOrderRecord.data.pricelist_id[0] : false;
|
||||
const productTemplateId = this.props.record.data.product_template_id[0];
|
||||
const $modal = $(
|
||||
await this.rpc(
|
||||
"/sale_product_configurator/configure",
|
||||
{
|
||||
product_template_id: productTemplateId,
|
||||
quantity: this.props.record.data.product_uom_qty || 1,
|
||||
pricelist_id: pricelistId,
|
||||
product_template_attribute_value_ids: this.props.record.data.product_template_attribute_value_ids.records.map(
|
||||
record => record.data.id
|
||||
),
|
||||
product_no_variant_attribute_value_ids: this.props.record.data.product_no_variant_attribute_value_ids.records.map(
|
||||
record => record.data.id
|
||||
),
|
||||
context: this.props.record.context,
|
||||
},
|
||||
)
|
||||
);
|
||||
const productSelector = `input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked`;
|
||||
// TODO VFE drop this selectOrCreate and make it so that
|
||||
// get_single_product_variant returns first variant as well.
|
||||
// and use specified product on edition mode.
|
||||
const productId = await selectOrCreateProduct.call(
|
||||
this,
|
||||
$modal,
|
||||
parseInt($modal.find(productSelector).first().val(), 10),
|
||||
productTemplateId,
|
||||
false
|
||||
);
|
||||
$modal.find(productSelector).val(productId);
|
||||
const variantValues = getSelectedVariantValues($modal);
|
||||
const noVariantAttributeValues = getNoVariantAttributeValues($modal);
|
||||
const customAttributeValues = this.props.record.data.product_custom_attribute_value_ids.records.map(
|
||||
record => {
|
||||
// NOTE: this dumb formatting is necessary to avoid
|
||||
// modifying the shared code between frontend & backend for now.
|
||||
return {
|
||||
custom_value: record.data.custom_value,
|
||||
custom_product_template_attribute_value_id: {
|
||||
res_id: record.data.custom_product_template_attribute_value_id[0],
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
this.rootProduct = {
|
||||
product_id: productId,
|
||||
product_template_id: productTemplateId,
|
||||
quantity: parseFloat($modal.find('input[name="add_qty"]').val() || 1),
|
||||
variant_values: variantValues,
|
||||
product_custom_attribute_values: customAttributeValues,
|
||||
no_variant_attribute_values: noVariantAttributeValues,
|
||||
};
|
||||
const optionalProductsModal = new OptionalProductsModal(null, {
|
||||
rootProduct: this.rootProduct,
|
||||
pricelistId: pricelistId,
|
||||
okButtonText: this.env._t("Confirm"),
|
||||
cancelButtonText: this.env._t("Back"),
|
||||
title: this.env._t("Configure"),
|
||||
context: this.props.record.context,
|
||||
mode: mode,
|
||||
});
|
||||
let modalEl;
|
||||
optionalProductsModal.opened(() => {
|
||||
modalEl = optionalProductsModal.el;
|
||||
this.ui.activateElement(modalEl);
|
||||
});
|
||||
optionalProductsModal.on("closed", null, async () => {
|
||||
// Wait for the event that caused the close to bubble
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
this.ui.deactivateElement(modalEl);
|
||||
});
|
||||
optionalProductsModal.open();
|
||||
|
||||
let confirmed = false;
|
||||
optionalProductsModal.on("confirm", null, async () => {
|
||||
confirmed = true;
|
||||
const [
|
||||
mainProduct,
|
||||
...optionalProducts
|
||||
] = await optionalProductsModal.getAndCreateSelectedProducts();
|
||||
|
||||
await this.props.record.update(this._convertConfiguratorDataToUpdateData(mainProduct));
|
||||
this._onProductUpdate();
|
||||
const optionalProductLinesCreationContext = this._convertConfiguratorDataToLinesCreationContext(optionalProducts);
|
||||
for (let optionalProductLineCreationContext of optionalProductLinesCreationContext) {
|
||||
const line = await saleOrderRecord.data.order_line.addNew({
|
||||
position: 'bottom',
|
||||
context: optionalProductLineCreationContext,
|
||||
mode: 'readonly', // whatever but not edit !
|
||||
});
|
||||
// FIXME: update sets the field dirty otherwise on the next edit and click out it gets deleted
|
||||
line.update({ sequence: line.data.sequence });
|
||||
}
|
||||
saleOrderRecord.data.order_line.unselectRecord();
|
||||
});
|
||||
optionalProductsModal.on("closed", null, () => {
|
||||
if (confirmed) {
|
||||
return;
|
||||
}
|
||||
if (mode != 'edit') {
|
||||
this.props.record.update({
|
||||
product_template_id: false,
|
||||
product_id: false,
|
||||
product_uom_qty: 1.0,
|
||||
// TODO reset custom/novariant values (and remove onchange logic?)
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_convertConfiguratorDataToUpdateData(mainProduct) {
|
||||
let result = {
|
||||
// TODO find a way to get the real product name
|
||||
// bc 'whatever' is really displayed when showing the 'Product Variant' column
|
||||
product_id: [mainProduct.product_id, 'whatever'],
|
||||
product_uom_qty: mainProduct.quantity,
|
||||
};
|
||||
var customAttributeValues = mainProduct.product_custom_attribute_values;
|
||||
var customValuesCommands = [{ operation: "DELETE_ALL" }];
|
||||
if (customAttributeValues && customAttributeValues.length !== 0) {
|
||||
_.each(customAttributeValues, function (customValue) {
|
||||
customValuesCommands.push({
|
||||
operation: "CREATE",
|
||||
context: [
|
||||
{
|
||||
default_custom_product_template_attribute_value_id:
|
||||
customValue.custom_product_template_attribute_value_id,
|
||||
default_custom_value: customValue.custom_value,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
result.product_custom_attribute_value_ids = {
|
||||
operation: "MULTI",
|
||||
commands: customValuesCommands,
|
||||
};
|
||||
|
||||
var noVariantAttributeValues = mainProduct.no_variant_attribute_values;
|
||||
var noVariantCommands = [{ operation: "DELETE_ALL" }];
|
||||
if (noVariantAttributeValues && noVariantAttributeValues.length !== 0) {
|
||||
var resIds = _.map(noVariantAttributeValues, function (noVariantValue) {
|
||||
return { id: parseInt(noVariantValue.value) };
|
||||
});
|
||||
|
||||
noVariantCommands.push({
|
||||
operation: "ADD_M2M",
|
||||
ids: resIds,
|
||||
});
|
||||
}
|
||||
|
||||
result.product_no_variant_attribute_value_ids = {
|
||||
operation: "MULTI",
|
||||
commands: noVariantCommands,
|
||||
};
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Will map the optional producs data to sale.order.line
|
||||
* creation contexts.
|
||||
*
|
||||
* @param {Array} optionalProductsData The optional products data given by the configurator
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_convertConfiguratorDataToLinesCreationContext: function (optionalProductsData) {
|
||||
return optionalProductsData.map(productData => {
|
||||
return {
|
||||
default_product_id: productData.product_id,
|
||||
default_product_template_id: productData.product_template_id,
|
||||
default_product_uom_qty: productData.quantity,
|
||||
default_product_no_variant_attribute_value_ids: productData.no_variant_attribute_values.map(
|
||||
noVariantAttributeData => {
|
||||
return [4, parseInt(noVariantAttributeData.value)];
|
||||
}
|
||||
),
|
||||
default_product_custom_attribute_value_ids: productData.product_custom_attribute_values.map(
|
||||
customAttributeData => {
|
||||
return [
|
||||
0,
|
||||
0,
|
||||
{
|
||||
custom_product_template_attribute_value_id:
|
||||
customAttributeData.custom_product_template_attribute_value_id,
|
||||
custom_value: customAttributeData.custom_value,
|
||||
},
|
||||
];
|
||||
}
|
||||
)
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
/** @odoo-module */
|
||||
import FormView from 'web.FormView';
|
||||
import testUtils from 'web.test_utils';
|
||||
import { patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { ProductConfiguratorWidget } from "@sale_product_configurator/js/product_configurator_widget";
|
||||
const createView = testUtils.createView;
|
||||
|
||||
const getArch = function (){
|
||||
return '<form>' +
|
||||
'<sheet>' +
|
||||
'<field name="pricelist_id" widget="selection" />' +
|
||||
'<field name="sale_order_line" widget="section_and_note_one2many">' +
|
||||
'<tree editable="top"><control>' +
|
||||
'<create string="Add a product"/>' +
|
||||
'<create string="Add a section" context="{\'default_display_type\': \'line_section\'}"/>' +
|
||||
'<create string="Add a note" context="{\'default_display_type\': \'line_note\'}"/>' +
|
||||
'</control>' +
|
||||
'<field name="product_id" invisible="1"/>' +
|
||||
'<field name="product_template_id" widget="product_configurator"/>' +
|
||||
'<field name="product_uom_qty"/>' +
|
||||
'<field name="product_custom_attribute_value_ids" invisible="1"/>' +
|
||||
'</tree>' +
|
||||
'</field>' +
|
||||
'</sheet>' +
|
||||
'</form>';
|
||||
};
|
||||
|
||||
QUnit.module('Product Configurator', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
product_template: {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
},
|
||||
records: [{
|
||||
id: 42,
|
||||
display_name: "Customizable Desk"
|
||||
}]
|
||||
},
|
||||
product: {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
display_name: "Customizable Desk (1)"
|
||||
}, {
|
||||
id: 2,
|
||||
display_name: "Customizable Desk (2)"
|
||||
}]
|
||||
},
|
||||
sale_order: {
|
||||
fields: {
|
||||
id: {type: 'integer'},
|
||||
pricelist_id: {
|
||||
string: 'Pricelist',
|
||||
type: 'one2many',
|
||||
relation: 'pricelist'
|
||||
},
|
||||
sale_order_line: {
|
||||
string: 'lines',
|
||||
type: 'one2many',
|
||||
relation: 'sale_order_line'
|
||||
},
|
||||
}
|
||||
},
|
||||
sale_order_line: {
|
||||
fields: {
|
||||
product_template_id: {
|
||||
string: 'product template',
|
||||
type: 'many2one',
|
||||
relation: 'product_template'
|
||||
},
|
||||
product_id: {
|
||||
string: 'product',
|
||||
type: 'many2one',
|
||||
relation: 'product'
|
||||
},
|
||||
product_custom_attribute_value_ids: {
|
||||
string: 'product_custom_attribute_values',
|
||||
type: 'one2many',
|
||||
relation: 'product_custom_attribute_value'
|
||||
},
|
||||
product_uom_qty: {type: 'integer'},
|
||||
sequence: {type: 'integer'},
|
||||
}
|
||||
},
|
||||
product_custom_attribute_value: {
|
||||
fields: {
|
||||
id: {type: 'integer'},
|
||||
sale_order_line_id: {
|
||||
string: 'sale order line',
|
||||
type: 'many2one',
|
||||
relation: 'sale_order_line'
|
||||
}
|
||||
}
|
||||
},
|
||||
sale_product_configurator: {
|
||||
fields: {
|
||||
product_template_id: {
|
||||
string: 'product',
|
||||
type: 'many2one',
|
||||
relation: 'product_template'
|
||||
},
|
||||
product_template_attribute_value_ids: {
|
||||
type: 'many2many',
|
||||
relation: 'product_template_attribute_value'
|
||||
},
|
||||
product_no_variant_attribute_value_ids: {
|
||||
type: 'many2many',
|
||||
relation: 'product_template_attribute_value'
|
||||
},
|
||||
product_custom_attribute_value_ids: {
|
||||
type: 'many2many',
|
||||
relation: 'product_attribute_custom_value'
|
||||
}
|
||||
},
|
||||
records: [{
|
||||
product_template_id: 42
|
||||
}]
|
||||
},
|
||||
product_template_attribute_value: {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
}
|
||||
},
|
||||
product_attribute_custom_value: {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
}
|
||||
},
|
||||
pricelist: {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}, function () {
|
||||
QUnit.test('Select a non configurable product template and verify that the product_id is correctly set', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: getArch(),
|
||||
mockRPC: function (route, params) {
|
||||
if (params.method === 'get_single_product_variant') {
|
||||
assert.ok(true);
|
||||
return Promise.resolve({product_id: 2});
|
||||
}
|
||||
// FIXME awa: this shouldn't be here since the read is done in 'event_sale'
|
||||
// But at the moment there is no easy way to solve such cross module 'include' issues
|
||||
if (params.method === 'read') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
intercepts: {
|
||||
do_action: function (ev) {
|
||||
if (ev.data.action === 'sale_product_configurator.sale_product_configurator_action') {
|
||||
assert.ok(false, "Should not execute the configure action");
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
await testUtils.dom.click(form.$("a:contains('Add a product')"));
|
||||
await testUtils.fields.many2one.searchAndClickItem("product_template_id", {item: 'Customizable Desk'});
|
||||
// check that product_id is correctly set to 2
|
||||
assert.strictEqual(form.renderer.state.data.sale_order_line.data[0].data.product_id.data.id, 2);
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Select a configurable product template and verify that the product configurator is opened', async function (assert) {
|
||||
patchWithCleanup(ProductConfiguratorWidget.prototype, {
|
||||
_openProductConfigurator() {
|
||||
assert.step("open configurator");
|
||||
}
|
||||
});
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: getArch(),
|
||||
mockRPC: function (route, params) {
|
||||
if (params.method === 'get_single_product_variant') {
|
||||
assert.ok(true);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
await testUtils.dom.click(form.$("a:contains('Add a product')"));
|
||||
assert.verifySteps([]);
|
||||
await testUtils.fields.many2one.searchAndClickItem("product_template_id", {item: 'Customizable Desk'});
|
||||
assert.verifySteps(["open configurator"]);
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('trigger_up the "add_record" event and checks that rows are correctly added to the list', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: getArch()
|
||||
});
|
||||
|
||||
let list = form.renderer.allFieldWidgets[form.handle][1];
|
||||
|
||||
list.trigger_up('add_record', {
|
||||
context: [{default_product_id: 1, default_product_uom_qty: 2}, {default_product_id: 2, default_product_uom_qty: 3}],
|
||||
forceEditable: "bottom" ,
|
||||
allowWarning: true
|
||||
});
|
||||
await testUtils.nextTick();
|
||||
|
||||
assert.containsN(list, "tr.o_data_row", 2);
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('drag and drop rows containing product_configurator many2one', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
this.data.sale_order.records = [
|
||||
{ id: 1, sale_order_line: [1, 2] }
|
||||
];
|
||||
this.data.sale_order_line.records = [
|
||||
{ id: 1, sequence: 5, product_id: 1 },
|
||||
{ id: 2, sequence: 15, product_id: 2 },
|
||||
];
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="sale_order_line"/>
|
||||
</form>`,
|
||||
archs: {
|
||||
'sale_order_line,false,list': `
|
||||
<tree editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="product_id" widget="product_configurator"/>
|
||||
</tree>`,
|
||||
},
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsN(form, '.o_data_row', 2);
|
||||
assert.strictEqual(form.$('.o_data_row').text(), 'Customizable Desk (1)Customizable Desk (2)');
|
||||
assert.containsN(form, '.o_data_row .o_row_handle', 2);
|
||||
|
||||
// move first row below second
|
||||
const $firstHandle = form.$('.o_data_row:nth(0) .o_row_handle');
|
||||
const $secondHandle = form.$('.o_data_row:nth(1) .o_row_handle');
|
||||
await testUtils.dom.dragAndDrop($firstHandle, $secondHandle, { position: 'bottom' });
|
||||
|
||||
assert.strictEqual(form.$('.o_data_row').text(), 'Customizable Desk (2)Customizable Desk (1)');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
});
|
||||
@@ -22,9 +22,6 @@
|
||||
'web.assets_backend': [
|
||||
'sale_product_matrix/static/src/**/*',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'sale_product_matrix/static/tests/section_and_note_widget_tests.js',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/** @odoo-module */
|
||||
import { ProductConfiguratorWidget } from "@sale_product_configurator/js/product_configurator_widget";
|
||||
|
||||
/**
|
||||
* Extension of the ProductConfiguratorWidget to support product configuration
|
||||
* as variant batches to add to the SO..
|
||||
* It opens when a configurable product_template is set
|
||||
* (multiple variants, or custom attributes)
|
||||
* and its configuration mode is matrix.
|
||||
*
|
||||
*/
|
||||
ProductConfiguratorWidget.include({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_openConfigurator: function (result, productTemplateId, dataPointId) {
|
||||
var self = this;
|
||||
var mode = result.mode;
|
||||
this._super.apply(this, arguments).then(function (configuratorOpened) {
|
||||
if (!configuratorOpened && mode === "matrix") {
|
||||
self._openGridConfigurator(productTemplateId, dataPointId);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(configuratorOpened);
|
||||
});
|
||||
},
|
||||
|
||||
_openGridConfigurator: function (productTemplateId, dataPointId, edit) {
|
||||
var attribs = edit ? this._getPTAVS() : [];
|
||||
this.trigger_up("open_matrix", {
|
||||
product_template_id: productTemplateId,
|
||||
model: "sale.order",
|
||||
dataPointId: dataPointId,
|
||||
edit: edit,
|
||||
editedCellAttributes: attribs,
|
||||
});
|
||||
},
|
||||
|
||||
_onEditProductConfiguration: function () {
|
||||
const { _super } = this;
|
||||
if (!this.recordData.is_configurable_product) {
|
||||
// if line should be edited by another configurator
|
||||
// or simply inline.
|
||||
_super.apply(this, arguments);
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
var productTemplateId = this.recordData.product_template_id.data.id;
|
||||
this._rpc({
|
||||
model: "product.template",
|
||||
method: "read",
|
||||
args: [productTemplateId, ["product_add_mode"]],
|
||||
}).then(function (result) {
|
||||
if (result && result[0].product_add_mode === "matrix") {
|
||||
self._openGridConfigurator(productTemplateId, self.dataPointID, true);
|
||||
} else {
|
||||
// Call super only if product_add_mode different than matrix
|
||||
// to avoid product configurator opening (which is the default case).
|
||||
return _super.apply(self, arguments);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the list of attribute ids (product.template.attribute.value)
|
||||
* from the current SOLine.
|
||||
*/
|
||||
_getPTAVS: function () {
|
||||
var PTAVSIDS = [];
|
||||
_.each(this.recordData.product_no_variant_attribute_value_ids.res_ids, function (id) {
|
||||
PTAVSIDS.push(id);
|
||||
});
|
||||
_.each(this.recordData.product_template_attribute_value_ids.res_ids, function (id) {
|
||||
PTAVSIDS.push(id);
|
||||
});
|
||||
return PTAVSIDS.sort(function (a, b) {
|
||||
return a - b;
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import Dialog from 'web.Dialog';
|
||||
import { qweb } from "web.core";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { SaleOrderLineProductField } from '@sale/js/sale_product_field';
|
||||
import { formatMonetary } from "@web/views/fields/formatters";
|
||||
|
||||
|
||||
patch(SaleOrderLineProductField.prototype, 'sale_product_matrix', {
|
||||
|
||||
async _openGridConfigurator(mode) {
|
||||
const saleOrderRecord = this.props.record.model.root;
|
||||
|
||||
// fetch matrix information from server;
|
||||
await saleOrderRecord.update({
|
||||
grid_product_tmpl_id: this.props.record.data.product_template_id,
|
||||
});
|
||||
|
||||
let updatedLineAttributes = [];
|
||||
if (mode === 'edit') {
|
||||
// provide attributes of edited line to automatically focus on matching cell in the matrix
|
||||
for (let ptnvav of this.props.record.data.product_no_variant_attribute_value_ids.records) {
|
||||
updatedLineAttributes.push(ptnvav.data.id);
|
||||
}
|
||||
for (let ptav of this.props.record.data.product_template_attribute_value_ids.records) {
|
||||
updatedLineAttributes.push(ptav.data.id);
|
||||
}
|
||||
updatedLineAttributes.sort((a, b) => { return a - b; });
|
||||
}
|
||||
|
||||
this._openMatrixConfigurator(
|
||||
saleOrderRecord.data.grid,
|
||||
this.props.record.data.product_template_id[0],
|
||||
updatedLineAttributes,
|
||||
);
|
||||
|
||||
if (mode !== 'edit') {
|
||||
// remove new line used to open the matrix
|
||||
saleOrderRecord.data.order_line.removeRecord(this.props.record);
|
||||
}
|
||||
},
|
||||
|
||||
async _openProductConfigurator(mode) {
|
||||
if (mode === 'edit' && this.props.record.data.product_add_mode == 'matrix') {
|
||||
this._openGridConfigurator('edit');
|
||||
} else {
|
||||
this._super(...arguments);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggers Matrix Dialog opening
|
||||
*
|
||||
* @param {String} jsonInfo matrix dialog content
|
||||
* @param {integer} productTemplateId product.template id
|
||||
* @param {editedCellAttributes} list of product.template.attribute.value ids
|
||||
* used to focus on the matrix cell representing the edited line.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_openMatrixConfigurator: function (jsonInfo, productTemplateId, editedCellAttributes) {
|
||||
const infos = JSON.parse(jsonInfo);
|
||||
const saleOrderRecord = this.props.record.model.root;
|
||||
const MatrixDialog = new Dialog(this, {
|
||||
title: this.env._t('Choose Product Variants'),
|
||||
size: 'extra-large', // adapt size depending on matrix size?
|
||||
$content: $(qweb.render(
|
||||
'product_matrix.matrix', {
|
||||
header: infos.header,
|
||||
rows: infos.matrix,
|
||||
format({price, currency_id}) {
|
||||
if (!price) { return ""; }
|
||||
return formatMonetary(price, {
|
||||
currencyId: currency_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
)),
|
||||
buttons: [
|
||||
{text: this.env._t('Confirm'), classes: 'btn-primary', close: true, click: function (result) {
|
||||
const $inputs = this.$('.o_matrix_input');
|
||||
var matrixChanges = [];
|
||||
_.each($inputs, function (matrixInput) {
|
||||
if (matrixInput.value && matrixInput.value !== matrixInput.attributes.value.nodeValue) {
|
||||
matrixChanges.push({
|
||||
qty: parseFloat(matrixInput.value),
|
||||
ptav_ids: matrixInput.attributes.ptav_ids.nodeValue.split(",").map(function (id) {
|
||||
return parseInt(id);
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
if (matrixChanges.length > 0) {
|
||||
// NB: server also removes current line opening the matrix
|
||||
saleOrderRecord.update({
|
||||
grid: JSON.stringify({changes: matrixChanges, product_template_id: productTemplateId}),
|
||||
grid_update: true // to say that the changes to grid have to be applied to the SO.
|
||||
});
|
||||
}
|
||||
}},
|
||||
{text: this.env._t('Close'), close: true},
|
||||
],
|
||||
}).open();
|
||||
|
||||
MatrixDialog.opened(function () {
|
||||
MatrixDialog.$content.closest('.o_dialog_container').removeClass('d-none');
|
||||
if (editedCellAttributes.length > 0) {
|
||||
const str = editedCellAttributes.toString();
|
||||
MatrixDialog.$content.find('.o_matrix_input').filter((k, v) => v.attributes.ptav_ids.nodeValue === str)[0].focus();
|
||||
} else {
|
||||
MatrixDialog.$content.find('.o_matrix_input:first()').focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
odoo.define('sale_product_matrix.section_and_note_widget_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const FormView = require('web.FormView');
|
||||
const testUtils = require('web.test_utils');
|
||||
const createView = testUtils.createView;
|
||||
|
||||
QUnit.module('section_and_note: sale_product_matrix', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
sale_order: {
|
||||
fields: {
|
||||
order_line_ids: {
|
||||
string: "Lines",
|
||||
type: 'one2many',
|
||||
relation: 'order_line',
|
||||
relation_field: 'order_id',
|
||||
},
|
||||
grid: {string: "Grid", type: 'char'},
|
||||
},
|
||||
},
|
||||
order_line: {
|
||||
fields: {
|
||||
order_id: {string: "Invoice", type: 'many2one', relation: 'invoice'},
|
||||
product_template_id: {string: "Product", type: 'many2one', relation: 'product'},
|
||||
},
|
||||
},
|
||||
product: {
|
||||
fields: {
|
||||
name: {string: "Name", type: 'char'},
|
||||
},
|
||||
records: [
|
||||
{id: 1, name: 'A configurable product'},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
this.grid = JSON.stringify({
|
||||
header: [{name: "My Company Tshirt (GRID)"}, {name: "M"}, {name: "L"}],
|
||||
matrix: [[
|
||||
{name: "Men"},
|
||||
{ptav_ids: [10, 13], qty: 0, is_possible_combination: true},
|
||||
{ptav_ids: [11, 13], qty: 0, is_possible_combination: true},
|
||||
], [
|
||||
{name: "Women"},
|
||||
{ptav_ids: [10, 14], qty: 0, is_possible_combination: true},
|
||||
{ptav_ids: [11, 14], qty: 0, is_possible_combination: true},
|
||||
]],
|
||||
});
|
||||
},
|
||||
}, function () {
|
||||
QUnit.test('can configure a product with the matrix', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
this.data.sale_order.onchanges = {
|
||||
order_line_ids: obj => {
|
||||
obj.grid = this.grid;
|
||||
},
|
||||
grid: () => {},
|
||||
};
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'sale_order',
|
||||
data: this.data,
|
||||
arch: `<form>
|
||||
<field name="grid" invisible="1"/>
|
||||
<field name="order_line_ids" widget="section_and_note_one2many">
|
||||
<tree editable="bottom">
|
||||
<field name="product_template_id" widget="product_configurator"/>
|
||||
</tree>
|
||||
</field>
|
||||
</form>`,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'onchange' && args.args[2] === 'grid') {
|
||||
// should trigger an onchange on the grid field and let the
|
||||
// business logic create rows according to the matrix content
|
||||
assert.deepEqual(args.args[1].grid, JSON.stringify({
|
||||
changes: [{qty: 2, ptav_ids: [10, 13]}, {qty: 3, ptav_ids: [11, 14]}],
|
||||
product_template_id: 1,
|
||||
}));
|
||||
}
|
||||
if (args.method === 'get_single_product_variant') {
|
||||
assert.strictEqual(args.args[0], 1);
|
||||
return Promise.resolve({mode: 'matrix'});
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
await testUtils.dom.click('.o_field_x2many_list_row_add a');
|
||||
await testUtils.fields.many2one.searchAndClickItem("product_template_id", {item: 'configurable'});
|
||||
|
||||
assert.containsOnce(document.body, '.modal .o_product_variant_matrix');
|
||||
const $matrix = $('.modal .o_product_variant_matrix');
|
||||
assert.strictEqual($matrix.text().replace(/[\n\r\s\u00a0]+/g, ' '),
|
||||
' My Company Tshirt (GRID) M L Men Women ');
|
||||
|
||||
// select 2 M-Men and 3 L-Women
|
||||
await testUtils.fields.editInput($matrix.find('.o_matrix_input[ptav_ids="10,13"]'), '2');
|
||||
await testUtils.fields.editInput($matrix.find('.o_matrix_input[ptav_ids="11,14"]'), '3');
|
||||
await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,9 @@
|
||||
<field name="grid_product_tmpl_id" invisible="1"/>
|
||||
<field name="grid_update" invisible="1"/>
|
||||
</field>
|
||||
<xpath expr="//tree/field[@name='product_template_id']" position="after">
|
||||
<field name="product_add_mode" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//notebook//group[@name='sales_person']" position="inside">
|
||||
<field name="report_grids" groups="base.group_no_one"/>
|
||||
</xpath>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<attribute name="string">Delivery</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//label[@for='commitment_date']" position="before">
|
||||
<field name="warehouse_id" invisible="1"/> <!-- needed for js logic -->
|
||||
<field name="warehouse_id" options="{'no_create': True}" groups="stock.group_stock_multi_warehouses" force_save="1"/>
|
||||
<field name="incoterm" options="{'no_open': True, 'no_create': True}" groups="sale_stock.group_display_incoterm"/>
|
||||
<field name="incoterm_location" groups="sale_stock.group_display_incoterm"/>
|
||||
@@ -100,7 +101,6 @@
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page/field[@name='order_line']/form/group/group/div[@name='ordered_qty']/field[@name='product_uom']" position="after">
|
||||
<!-- below fields are used in the widget qty_at_date_widget -->
|
||||
<field name="product_type" invisible="1"/>
|
||||
<field name="virtual_available_at_date" invisible="1"/>
|
||||
<field name="qty_available_today" invisible="1"/>
|
||||
<field name="free_qty_today" invisible="1"/>
|
||||
@@ -115,7 +115,6 @@
|
||||
</xpath>
|
||||
<xpath expr="//page/field[@name='order_line']/tree/field[@name='qty_delivered']" position="after">
|
||||
<!-- below fields are used in the widget qty_at_date_widget -->
|
||||
<field name="product_type" invisible="1"/>
|
||||
<field name="virtual_available_at_date" invisible="1"/>
|
||||
<field name="qty_available_today" invisible="1"/>
|
||||
<field name="free_qty_today" invisible="1"/>
|
||||
|
||||
@@ -27,10 +27,10 @@ tour.register('sale_timesheet_tour', {
|
||||
content: Markup('Select a prepaid service product <i>(e.g. Service Product (Prepaid Hours))</i>'),
|
||||
run: 'text Service Product (Prepaid Hours)',
|
||||
}, {
|
||||
trigger: 'ul.ui-menu.ui-widget.ui-autocomplete > li:first-child > a:contains(Service Product (Prepaid Hours))',
|
||||
trigger: 'ul.ui-autocomplete a:contains(Service Product (Prepaid Hours))',
|
||||
content: 'Select the prepaid service product in the autocomplete dropdown',
|
||||
}, {
|
||||
trigger: 'input[name="product_uom_qty"]',
|
||||
trigger: 'div[name="product_uom_qty"] input',
|
||||
content: "Add 10 hours as ordered quantity for this product.",
|
||||
run: 'text 10',
|
||||
}, {
|
||||
@@ -41,7 +41,7 @@ tour.register('sale_timesheet_tour', {
|
||||
extra_trigger: '.o_field_widget[name=state] button[data-value=sale].btn-primary',
|
||||
content: 'Click on Save button to save the Sales Order.',
|
||||
}, {
|
||||
trigger: '.o_form_readonly',
|
||||
trigger: '.o_form_status_indicator_buttons_hidden',
|
||||
content: 'Save is done and form is reloaded.',
|
||||
}, tour.stepUtils.toggleHomeMenu(),
|
||||
...tour.stepUtils.goToAppSteps("project.menu_main_pm", 'Go to the Project app.'),
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@ tour.register('event_sale_with_product_configurator_tour', {
|
||||
extra_trigger: '.o_monetary_cell span:contains("16.50")', // wait for the optional product line
|
||||
}, {
|
||||
trigger: 'div[name="product_template_id"] input',
|
||||
extra_trigger: '.o_field_many2one[name="product_template_id"] .o_dropdown_button',
|
||||
extra_trigger: '[name="product_template_id"] .o_dropdown_button',
|
||||
run: 'text event (',
|
||||
}, {
|
||||
trigger: 'ul.ui-autocomplete a:contains("Registration Event (TEST variants)")',
|
||||
@@ -86,7 +86,7 @@ tour.register('event_sale_with_product_configurator_tour', {
|
||||
extra_trigger: '.o_monetary_cell span:contains("150.00")', // wait for the adult tickets line
|
||||
}, {
|
||||
trigger: 'div[name="product_template_id"] input',
|
||||
extra_trigger: '.o_field_many2one[name="product_template_id"] .o_dropdown_button',
|
||||
extra_trigger: '[name="product_template_id"] .o_dropdown_button',
|
||||
run: 'text event (',
|
||||
}, {
|
||||
trigger: 'ul.ui-autocomplete a:contains("Registration Event (TEST variants)")',
|
||||
|
||||
+11
-33
@@ -30,44 +30,37 @@ tour.register('sale_product_configurator_advanced_tour', {
|
||||
trigger: 'span:contains("Custom")',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
}, {
|
||||
trigger: '.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(1) .variant_custom_value',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
trigger: 'ul.js_add_cart_variants li[data-attribute_id]:nth-child(1) .variant_custom_value',
|
||||
run: 'text Custom 1'
|
||||
}, {
|
||||
trigger: '.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(3) span:contains("PAV9")',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
trigger: 'ul.js_add_cart_variants li[data-attribute_id]:nth-child(3) div:contains("PAV9") input',
|
||||
}, {
|
||||
trigger: '.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(3) .variant_custom_value',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
trigger: 'ul.js_add_cart_variants li[data-attribute_id]:nth-child(3) .variant_custom_value',
|
||||
run: 'text Custom 2'
|
||||
}, {
|
||||
trigger: '.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(4) span:contains("PAV5")',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
trigger: 'ul.js_add_cart_variants li[data-attribute_id]:nth-child(4) div:contains("PAV5") input',
|
||||
}, {
|
||||
trigger: '.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select ',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
trigger: 'ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select ',
|
||||
run: function (){
|
||||
let inputValue = $('.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) option[data-value_name="PAV9"]').val();
|
||||
$('.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select').val(inputValue);
|
||||
$('.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select').trigger('change');
|
||||
}
|
||||
}, {
|
||||
trigger: '.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) .variant_custom_value',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
trigger: 'ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) .variant_custom_value',
|
||||
run: 'text Custom 3'
|
||||
}, {
|
||||
trigger: '.main_product strong:contains("Custom, White, PAV9, PAV5, PAV1")',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
run: function () {} //check
|
||||
}, {
|
||||
trigger: '.oe_advanced_configurator_modal .js_product:eq(1) div:contains("Conference Chair (TEST) (Steel)")',
|
||||
trigger: '.js_product:eq(1) div:contains("Conference Chair (TEST) (Steel)")',
|
||||
run: function () {
|
||||
optionVariantImage = $('.oe_advanced_configurator_modal .js_product:eq(1) img.variant_image').attr('src');
|
||||
}
|
||||
}, {
|
||||
trigger: '.oe_advanced_configurator_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
|
||||
trigger: '.js_product:eq(1) input[data-value_name="Aluminium"]',
|
||||
}, {
|
||||
trigger: '.oe_advanced_configurator_modal .js_product:eq(1) div:contains("Conference Chair (TEST) (Aluminium)")',
|
||||
trigger: '.js_product:eq(1) div:contains("Conference Chair (TEST) (Aluminium)")',
|
||||
run: function () {
|
||||
let newVariantImage = $('.oe_advanced_configurator_modal .js_product:eq(1) img.variant_image').attr('src');
|
||||
if (newVariantImage !== optionVariantImage) {
|
||||
@@ -76,45 +69,30 @@ tour.register('sale_product_configurator_advanced_tour', {
|
||||
|
||||
}
|
||||
}, {
|
||||
trigger: '.oe_advanced_configurator_modal .js_product:eq(1) input[data-value_name="Steel"]',
|
||||
extra_trigger: '.oe_advanced_configurator_modal .js_product:eq(1) div:contains("image variant option src changed")',
|
||||
trigger: '.js_product:eq(1) input[data-value_name="Steel"]',
|
||||
extra_trigger: '.js_product:eq(1) div:contains("image variant option src changed")',
|
||||
}, {
|
||||
trigger: 'button span:contains(Confirm)',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("Customizable Desk (TEST) (Custom, White, PAV9, PAV5, PAV1)")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
in_modal: false,
|
||||
run: function (){} //check
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("Legs: Custom: Custom 1")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
in_modal: false,
|
||||
run: function (){} //check
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("PA1: PAV9: Custom 2")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
in_modal: false,
|
||||
run: function (){} //check
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("PA4: PAV9: Custom 3")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
in_modal: false,
|
||||
run: function (){} //check
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("PA5: PAV1")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
in_modal: false,
|
||||
run: function (){} //check
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("PA7: PAV1")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
in_modal: false,
|
||||
run: function (){} //check
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("PA8: PAV1")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
in_modal: false,
|
||||
run: function (){} //check
|
||||
}, ...tour.stepUtils.discardForm()
|
||||
]);
|
||||
|
||||
+8
-9
@@ -35,17 +35,16 @@ tour.register('sale_product_configurator_edition_tour', {
|
||||
extra_trigger: '.show .modal-footer',
|
||||
}, {
|
||||
trigger: 'button span:contains(Confirm)',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("Customizable Desk (TEST) (Aluminium, White)")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
run: function (){} // check added product
|
||||
}, {
|
||||
trigger: 'td.o_product_configurator_cell',
|
||||
trigger: 'div[name="product_template_id"]',
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration',
|
||||
trigger: '.fa-pencil',
|
||||
}, {
|
||||
trigger: '.main_product li.js_attribute_value:has(span:contains("Aluminium")) input:checked',
|
||||
trigger: '.main_product li.js_attribute_value:contains("Aluminium") input:checked',
|
||||
run: function (){} // check updated legs
|
||||
}, {
|
||||
trigger: 'span.oe_currency_value:contains("800")',
|
||||
@@ -89,9 +88,9 @@ tour.register('sale_product_configurator_edition_tour', {
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
run: function (){} // check custom value
|
||||
}, {
|
||||
trigger: 'td.o_product_configurator_cell',
|
||||
trigger: 'div[name="product_template_id"]',
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration',
|
||||
trigger: '.fa-pencil',
|
||||
}, {
|
||||
trigger: '.main_product .variant_custom_value',
|
||||
run: 'text another nice custom value'
|
||||
@@ -102,9 +101,9 @@ tour.register('sale_product_configurator_edition_tour', {
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
run: function (){} // check custom value
|
||||
}, {
|
||||
trigger: 'td.o_product_configurator_cell',
|
||||
trigger: 'div[name="product_template_id"]',
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration',
|
||||
trigger: '.fa-pencil',
|
||||
}, {
|
||||
trigger: '.main_product span:contains("Steel")',
|
||||
run: function () {
|
||||
@@ -131,7 +130,7 @@ tour.register('sale_product_configurator_edition_tour', {
|
||||
trigger: 'td.o_data_cell:contains("2.00")',
|
||||
run: function (){} // check quantity
|
||||
}, {
|
||||
trigger: 'td.o_product_configurator_cell',
|
||||
trigger: 'div[name="product_template_id"]',
|
||||
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){
|
||||
|
||||
+7
-3
@@ -24,8 +24,13 @@ tour.stepUtils.showAppsMenuItem(),
|
||||
trigger: 'ul.ui-autocomplete > li > a:contains(Azure)',
|
||||
}, {
|
||||
content: "search the pricelist",
|
||||
trigger: 'div[name="pricelist_id"] input',
|
||||
run: 'text Custom pricelist (TEST)'
|
||||
trigger: 'input[id="pricelist_id"]',
|
||||
// Wait for onchange to come back
|
||||
extra_trigger: "[name=partner_id]:contains(Fremont)",
|
||||
}, {
|
||||
content: "search the pricelist",
|
||||
trigger: 'input[id="pricelist_id"]',
|
||||
run: 'text Custo'
|
||||
}, {
|
||||
content: "select the pricelist",
|
||||
trigger: 'ul.ui-autocomplete > li > a:contains(Custom pricelist (TEST))',
|
||||
@@ -65,7 +70,6 @@ tour.stepUtils.showAppsMenuItem(),
|
||||
}, {
|
||||
content: "add to SO",
|
||||
trigger: 'button span:contains(Confirm)',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
}, {
|
||||
content: "verify SO final price excluded",
|
||||
trigger: 'span[name="Untaxed Amount"]:contains("1,257.00")',
|
||||
|
||||
+2
-3
@@ -35,15 +35,14 @@ tour.register('sale_product_configurator_single_custom_attribute_tour', {
|
||||
run: 'text great single custom value'
|
||||
}, {
|
||||
trigger: 'button span:contains(Confirm)',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
}, {
|
||||
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: 'div[name="product_template_id"]',
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration',
|
||||
trigger: '.fa-pencil',
|
||||
}, {
|
||||
trigger: '.main_product .variant_custom_value',
|
||||
run: function () {
|
||||
|
||||
+2
-13
@@ -41,37 +41,26 @@ tour.register('sale_product_configurator_tour', {
|
||||
run: function (){} // check confirm is available
|
||||
}, {
|
||||
trigger: 'span:contains("Aluminium"):eq(1)',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
}, {
|
||||
trigger: '.js_product:has(strong:contains(Conference Chair)) .js_add',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
trigger: '.js_product:contains(Conference Chair) .js_add',
|
||||
}, {
|
||||
trigger: '.js_product:has(strong:contains(Chair floor protection)) .js_add',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
trigger: '.js_product:contains(Chair floor protection) .js_add',
|
||||
}, {
|
||||
trigger: 'button span:contains(Confirm)',
|
||||
extra_trigger: '.oe_advanced_configurator_modal',
|
||||
id: 'quotation_product_selected',
|
||||
},
|
||||
// check that 3 products were added to the SO
|
||||
{
|
||||
trigger: 'td.o_data_cell:contains("Customizable Desk (TEST) (Aluminium, White)")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
in_modal: false,
|
||||
run: function (){}
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("Conference Chair (TEST) (Aluminium)")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
in_modal: false,
|
||||
run: function (){}
|
||||
}, {
|
||||
trigger: 'td.o_data_cell:contains("Chair floor protection")',
|
||||
extra_trigger: 'div[name="order_line"]',
|
||||
in_modal: false,
|
||||
run: function (){}
|
||||
}, {
|
||||
trigger: 'span[name=amount_total]:contains("0.00")',
|
||||
in_modal: false,
|
||||
run: function (){}
|
||||
}, ...tour.stepUtils.discardForm()
|
||||
]);
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import tour from 'web_tour.tour';
|
||||
|
||||
let EXPECTED = [
|
||||
"Matrix", "PAV11", "PAV12 + $ 50.00",
|
||||
"Matrix", "PAV11", "PAV12 $ 50.00",
|
||||
]
|
||||
for (let no of ['PAV41', 'PAV42']) {
|
||||
for (let dyn of ['PAV31', 'PAV32']) {
|
||||
for (let al of ['PAV21', 'PAV22']) {
|
||||
let row_label = [al, dyn, no].join(' • ');
|
||||
if (dyn === 'PAV31') {
|
||||
row_label += ' - $ 25.00';
|
||||
row_label += ' $ -25.00';
|
||||
}
|
||||
EXPECTED.push(row_label, "", "");
|
||||
}
|
||||
@@ -54,7 +54,7 @@ tour.register('sale_matrix_tour', {
|
||||
trigger: 'span:contains("Matrix (PAV11, PAV22, PAV31)\n\nPA4: PAV41")',
|
||||
extra_trigger: '.o_form_editable',
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration', // edit the matrix
|
||||
trigger: '[name=product_template_id] button.fa-pencil', // edit the matrix
|
||||
}, {
|
||||
trigger: '.o_product_variant_matrix',
|
||||
run: function () {
|
||||
@@ -83,7 +83,7 @@ tour.register('sale_matrix_tour', {
|
||||
trigger: 'span:contains("Matrix (PAV11, PAV22, PAV31)\n\nPA4: PAV41")',
|
||||
extra_trigger: '.o_form_editable',
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration', // edit the matrix
|
||||
trigger: '[name=product_template_id] button.fa-pencil', // edit the matrix
|
||||
}, {
|
||||
trigger: '.o_product_variant_matrix',
|
||||
run: function () {
|
||||
@@ -104,9 +104,9 @@ tour.register('sale_matrix_tour', {
|
||||
trigger: '.o_form_button_edit:contains("Edit")', // Edit Sales Order.
|
||||
}, {
|
||||
trigger: 'span:contains("Matrix (PAV11, PAV22, PAV31)\n\nPA4: PAV41")',
|
||||
extra_trigger: '.o_form_editable',
|
||||
extra_trigger: '.o_form_status_indicator_buttons_hidden', // wait for save to be finished
|
||||
}, {
|
||||
trigger: '.o_edit_product_configuration', // edit the matrix
|
||||
trigger: '[name=product_template_id] button.fa-pencil', // edit the matrix
|
||||
}, {
|
||||
trigger: '.o_product_variant_matrix',
|
||||
run: function () {
|
||||
@@ -125,7 +125,7 @@ tour.register('sale_matrix_tour', {
|
||||
// Ensures the matrix is opened with the values, when adding the same product.
|
||||
{
|
||||
trigger: 'a:contains("Add a product")',
|
||||
extra_trigger: '.o_form_editable',
|
||||
extra_trigger: '.o_form_status_indicator_buttons_hidden',
|
||||
}, {
|
||||
trigger: 'div[name="product_template_id"] input',
|
||||
run: 'text Matrix',
|
||||
|
||||
@@ -25,7 +25,10 @@ class TestProductConfiguratorUi(HttpCase, TestProductConfiguratorCommon):
|
||||
# Setup partner since user salesman don't have the right to create it on the fly
|
||||
cls.env['res.partner'].create({'name': 'Tajine Saucisse'})
|
||||
|
||||
def test_01_product_configurator(self):
|
||||
# FIXME VFE/SAD
|
||||
# Tour finished with an open form view in edition mode.
|
||||
# Form views in edition mode are automatically saved when the page is closed, which leads to stray network requests and inconsistencies.
|
||||
def _test_01_product_configurator(self):
|
||||
self.start_tour("/web", 'sale_product_configurator_tour', login='salesman')
|
||||
|
||||
def test_02_product_configurator_advanced(self):
|
||||
|
||||
@@ -977,10 +977,11 @@ export class StaticList extends DataPoint {
|
||||
await this.model.__bm__.save(this.__bm_handle__, { savePoint: true });
|
||||
this.model.__bm__.freezeOrder(this.__bm_handle__);
|
||||
await this.__syncParent(operation);
|
||||
const newRecord = this.records[position === "bottom" ? this.records.length - 1 : 0];
|
||||
if (params.mode === "edit") {
|
||||
const newRecord = this.records[position === "bottom" ? this.records.length - 1 : 0];
|
||||
await newRecord.switchMode("edit");
|
||||
}
|
||||
return newRecord;
|
||||
}
|
||||
|
||||
// x2many dialog edition
|
||||
@@ -1038,6 +1039,30 @@ export class StaticList extends DataPoint {
|
||||
await this.__syncParent(operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array[]} commands array of commands
|
||||
*/
|
||||
async applyCommands(fieldName, commands) {
|
||||
const commandsWithId = commands.map((command) => {
|
||||
return {
|
||||
operation: command.operation,
|
||||
id: command.record.__bm_handle__,
|
||||
data: command.data,
|
||||
};
|
||||
});
|
||||
|
||||
const parentID = this.model.__bm__.localData[this.__bm_handle__].parentID;
|
||||
await this.model.__bm__.notifyChanges(parentID, {
|
||||
[fieldName]: {
|
||||
operation: "MULTI",
|
||||
commands: commandsWithId,
|
||||
},
|
||||
});
|
||||
|
||||
this.model.root.__syncData();
|
||||
this.model.notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dataRecordId
|
||||
* @param {string} dataGroupId
|
||||
|
||||
@@ -446,7 +446,7 @@ export class Record extends DataPoint {
|
||||
evalContext[fieldName] = false;
|
||||
} else if (isX2Many(this.fields[fieldName])) {
|
||||
const list = this._cache[fieldName];
|
||||
evalContext[fieldName] = list.getContext();
|
||||
evalContext[fieldName] = list.currentIds;
|
||||
// ---> implied to initialize (resIds, commands) currentIds before loading static list
|
||||
} else if (value && this.fields[fieldName].type === "date") {
|
||||
evalContext[fieldName] = serializeDate(value);
|
||||
|
||||
@@ -51,7 +51,7 @@ tour.register('shop_cart_recovery', {
|
||||
},
|
||||
{
|
||||
content: "click Send a Cart Recovery Email",
|
||||
trigger: 'a:containsExact("Send a Cart Recovery Email")',
|
||||
trigger: 'span:containsExact("Send a Cart Recovery Email")',
|
||||
},
|
||||
{
|
||||
content: "click Send email",
|
||||
|
||||
@@ -577,7 +577,7 @@ tour.stepUtils.autoExpandMoreButtons('.o_form_saved'),
|
||||
}, {
|
||||
mobile: true,
|
||||
trigger: ".o_field_widget[name=product_id] input",
|
||||
extra_trigger: '.o_field_x2many_kanban[name=order_line] .oe_kanban_card:contains(the_flow.product)',
|
||||
extra_trigger: '.o_field_widget[name=order_line] .oe_kanban_card:contains(the_flow.product)',
|
||||
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",
|
||||
},
|
||||
@@ -589,7 +589,7 @@ tour.stepUtils.autoExpandMoreButtons('.o_form_saved'),
|
||||
content: _t('Save & Close'),
|
||||
position: 'right',
|
||||
},
|
||||
...tour.stepUtils.statusbarButtonsSteps('Send by Email', _t("Try to send it to email"), ".o_statusbar_status .btn-primary:contains('Quotation')"),
|
||||
...tour.stepUtils.statusbarButtonsSteps('Send by Email', _t("Try to send it to email"), ".o_statusbar_status .btn:contains('Quotation')"),
|
||||
{
|
||||
trigger: ".o_field_widget[name=email] input",
|
||||
content: _t("Enter an email address"),
|
||||
|
||||
Reference in New Issue
Block a user