summaryrefslogtreecommitdiff
path: root/addons/sale_product_configurator/static
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/sale_product_configurator/static
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/sale_product_configurator/static')
-rw-r--r--addons/sale_product_configurator/static/src/js/product_configurator_controller.js313
-rw-r--r--addons/sale_product_configurator/static/src/js/product_configurator_modal.js514
-rw-r--r--addons/sale_product_configurator/static/src/js/product_configurator_renderer.js133
-rw-r--r--addons/sale_product_configurator/static/src/js/product_configurator_view.js20
-rw-r--r--addons/sale_product_configurator/static/src/js/product_configurator_widget.js378
-rw-r--r--addons/sale_product_configurator/static/tests/product_configurator.test.js309
-rw-r--r--addons/sale_product_configurator/static/tests/tours/product_configurator_advanced_ui.js165
-rw-r--r--addons/sale_product_configurator/static/tests/tours/product_configurator_edition_ui.js160
-rw-r--r--addons/sale_product_configurator/static/tests/tours/product_configurator_optional_products_ui.js79
-rw-r--r--addons/sale_product_configurator/static/tests/tours/product_configurator_pricelist_ui.js99
-rw-r--r--addons/sale_product_configurator/static/tests/tours/product_configurator_single_custom_attribute_ui.js79
-rw-r--r--addons/sale_product_configurator/static/tests/tours/product_configurator_ui.js95
12 files changed, 2344 insertions, 0 deletions
diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_controller.js b/addons/sale_product_configurator/static/src/js/product_configurator_controller.js
new file mode 100644
index 00000000..008e6e65
--- /dev/null
+++ b/addons/sale_product_configurator/static/src/js/product_configurator_controller.js
@@ -0,0 +1,313 @@
+odoo.define('sale_product_configurator.ProductConfiguratorFormController', function (require) {
+"use strict";
+
+var core = require('web.core');
+var _t = core._t;
+var FormController = require('web.FormController');
+var OptionalProductsModal = require('sale_product_configurator.OptionalProductsModal');
+
+var ProductConfiguratorFormController = FormController.extend({
+ custom_events: _.extend({}, FormController.prototype.custom_events, {
+ field_changed: '_onFieldChanged',
+ handle_add: '_handleAdd'
+ }),
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self.$el.addClass('o_product_configurator');
+ });
+ },
+ /**
+ * We need to first load the template of the selected product and then render the content
+ * to avoid a flicker when the modal is opened.
+ *
+ * @override
+ */
+ willStart: function () {
+ var def = this._super.apply(this, arguments);
+ if (this.initialState.data.product_template_id) {
+ return this._configureProduct(
+ this.initialState.data.product_template_id.data.id
+ ).then(function () {
+ return def;
+ });
+ }
+
+ return def;
+ },
+ /**
+ * Showing this window is useless for configuratorMode 'options' as this form view
+ * is used as a bridge between SO lines and optional products.
+ *
+ * Placed here because it's the only method that is called after the modal is rendered.
+ *
+ * @override
+ */
+ renderButtons: function () {
+ this._super.apply(this, arguments);
+
+ if (this.renderer.state.context.configuratorMode === 'options') {
+ this.$el.closest('.modal').addClass('d-none');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+
+ /**
+ * We need to override the default click behavior for our "Add" button
+ * because there is a possibility that this product has optional products.
+ * If so, we need to display an extra modal to choose the options.
+ *
+ * @override
+ */
+ _onButtonClicked: function (event) {
+ if (event.stopPropagation) {
+ event.stopPropagation();
+ }
+ var attrs = event.data.attrs;
+ if (attrs.special === 'cancel') {
+ this._super.apply(this, arguments);
+ } else {
+ if (!this.$el
+ .parents('.modal')
+ .find('.o_sale_product_configurator_add')
+ .hasClass('disabled')) {
+ this._handleAdd();
+ }
+ }
+ },
+ /**
+ * This is overridden to allow catching the "select" event on our product template select field.
+ *
+ * @override
+ * @private
+ */
+ _onFieldChanged: function (event) {
+ this._super.apply(this, arguments);
+
+ var self = this;
+ var productId = event.data.changes.product_template_id.id;
+
+ // check to prevent traceback when emptying the field
+ if (!productId) {
+ return;
+ }
+
+ this._configureProduct(event.data.changes.product_template_id.id)
+ .then(function () {
+ self.renderer.renderConfigurator(self.renderer.configuratorHtml);
+ });
+ },
+
+ /**
+ * Renders the "variants" part of the wizard
+ *
+ * @param {integer} productTemplateId
+ */
+ _configureProduct: function (productTemplateId) {
+ var self = this;
+ var initialProduct = this.initialState.data.product_template_id;
+ var changed = initialProduct && initialProduct.data.id !== productTemplateId;
+ var data = this.renderer.state.data;
+ return this._rpc({
+ route: '/sale_product_configurator/configure',
+ params: {
+ product_template_id: productTemplateId,
+ pricelist_id: this.renderer.pricelistId,
+ add_qty: data.quantity,
+ product_template_attribute_value_ids: changed ? [] : this._getAttributeValueIds(
+ data.product_template_attribute_value_ids
+ ),
+ product_no_variant_attribute_value_ids: changed ? [] : this._getAttributeValueIds(
+ data.product_no_variant_attribute_value_ids
+ )
+ }
+ }).then(function (configurator) {
+ self.renderer.configuratorHtml = configurator;
+ });
+ },
+ /**
+ * When the user adds a product that has optional products, we need to display
+ * a window to allow the user to choose these extra options.
+ *
+ * This will also create the product if it's in "dynamic" mode
+ * (see product_attribute.create_variant)
+ *
+ * If "self.renderer.state.context.configuratorMode" is 'edit', this will only send
+ * the main product with its changes.
+ *
+ * As opposed to the 'add' mode that will add the main product AND all the configured optional products.
+ *
+ * A third mode, 'options', is available for products that don't have a configuration but have
+ * optional products to select. This will bypass the configuration step and open the
+ * options modal directly.
+ *
+ * @private
+ */
+ _handleAdd: function () {
+ var self = this;
+ var $modal = this.$el;
+ var productSelector = [
+ 'input[type="hidden"][name="product_id"]',
+ 'input[type="radio"][name="product_id"]:checked'
+ ];
+
+ var productId = parseInt($modal.find(productSelector.join(', ')).first().val(), 10);
+ var productTemplateId = $modal.find('.product_template_id').val();
+ this.renderer.selectOrCreateProduct(
+ $modal,
+ productId,
+ productTemplateId,
+ false
+ ).then(function (productId) {
+ $modal.find(productSelector.join(', ')).val(productId);
+
+ var variantValues = self
+ .renderer
+ .getSelectedVariantValues($modal.find('.js_product'));
+
+ var productCustomVariantValues = self
+ .renderer
+ .getCustomVariantValues($modal.find('.js_product'));
+
+ var noVariantAttributeValues = self
+ .renderer
+ .getNoVariantAttributeValues($modal.find('.js_product'));
+
+ self.rootProduct = {
+ product_id: productId,
+ product_template_id: parseInt(productTemplateId),
+ quantity: parseFloat($modal.find('input[name="add_qty"]').val() || 1),
+ variant_values: variantValues,
+ product_custom_attribute_values: productCustomVariantValues,
+ no_variant_attribute_values: noVariantAttributeValues
+ };
+
+ if (self.renderer.state.context.configuratorMode === 'edit') {
+ // edit mode only takes care of main product
+ self._onAddRootProductOnly();
+ return;
+ }
+
+ self.optionalProductsModal = new OptionalProductsModal($('body'), {
+ rootProduct: self.rootProduct,
+ pricelistId: self.renderer.pricelistId,
+ okButtonText: _t('Confirm'),
+ cancelButtonText: _t('Back'),
+ title: _t('Configure'),
+ context: self.initialState.context,
+ previousModalHeight: self.$el.closest('.modal-content').height()
+ }).open();
+
+ self.optionalProductsModal.on('options_empty', null,
+ // no optional products found for this product, only add the root product
+ self._onAddRootProductOnly.bind(self));
+
+ self.optionalProductsModal.on('update_quantity', null,
+ self._onOptionsUpdateQuantity.bind(self));
+
+ self.optionalProductsModal.on('confirm', null,
+ self._onModalConfirm.bind(self));
+
+ self.optionalProductsModal.on('closed', null,
+ self._onModalClose.bind(self));
+ });
+ },
+
+ /**
+ * Add root product only and forget optional products.
+ * Used when product has no optional products and in 'edit' mode.
+ *
+ * @private
+ */
+ _onAddRootProductOnly: function () {
+ this._addProducts([this.rootProduct]);
+ },
+
+ /**
+ * Add all selected products
+ *
+ * @private
+ */
+ _onModalConfirm: function () {
+ this._wasConfirmed = true;
+ this._addProducts(this.optionalProductsModal.getSelectedProducts());
+ },
+
+ /**
+ * When the optional products modal is closed (and not confirmed) on 'options' mode,
+ * this window should also be closed immediately.
+ *
+ * @private
+ */
+ _onModalClose: function () {
+ if (this.renderer.state.context.configuratorMode === 'options'
+ && this._wasConfirmed !== true) {
+ this.do_action({type: 'ir.actions.act_window_close'});
+ }
+ },
+
+ /**
+ * Update product configurator form
+ * when quantity is updated in the optional products window
+ *
+ * @private
+ * @param {integer} quantity
+ */
+ _onOptionsUpdateQuantity: function (quantity) {
+ this.$el
+ .find('input[name="add_qty"]')
+ .val(quantity)
+ .trigger('change');
+ },
+
+ /**
+ * This triggers the close action for the window and
+ * adds the product as the "infos" parameter.
+ * It will allow the caller (typically the product_configurator widget) of this window
+ * to handle the added products.
+ *
+ * @private
+ * @param {Array} products the list of added products
+ * {integer} products.product_id: the id of the product
+ * {integer} products.quantity: the added quantity for this product
+ * {Array} products.product_custom_attribute_values:
+ * see variant_mixin.getCustomVariantValues
+ * {Array} products.no_variant_attribute_values:
+ * see variant_mixin.getNoVariantAttributeValues
+ */
+ _addProducts: function (products) {
+ this.do_action({type: 'ir.actions.act_window_close', infos: {
+ mainProduct: products[0],
+ options: products.slice(1)
+ }});
+ },
+ /**
+ * Extracts the ids from the passed attributeValueIds and returns them
+ * as a plain array.
+ *
+ * @param {Array} attributeValueIds
+ */
+ _getAttributeValueIds: function (attributeValueIds) {
+ if (!attributeValueIds || attributeValueIds.length === 0) {
+ return false;
+ }
+
+ var result = [];
+ _.each(attributeValueIds.data, function (attributeValue) {
+ result.push(attributeValue.data.id);
+ });
+
+ return result;
+ }
+});
+
+return ProductConfiguratorFormController;
+
+});
diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_modal.js b/addons/sale_product_configurator/static/src/js/product_configurator_modal.js
new file mode 100644
index 00000000..b495100a
--- /dev/null
+++ b/addons/sale_product_configurator/static/src/js/product_configurator_modal.js
@@ -0,0 +1,514 @@
+odoo.define('sale_product_configurator.OptionalProductsModal', function (require) {
+ "use strict";
+
+var ajax = require('web.ajax');
+var Dialog = require('web.Dialog');
+const OwlDialog = require('web.OwlDialog');
+var ServicesMixin = require('web.ServicesMixin');
+var VariantMixin = require('sale.VariantMixin');
+
+var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
+ events: _.extend({}, Dialog.prototype.events, VariantMixin.events, {
+ 'click a.js_add, a.js_remove': '_onAddOrRemoveOption',
+ 'click button.js_add_cart_json': 'onClickAddCartJSON',
+ 'change .in_cart input.js_quantity': '_onChangeQuantity',
+ 'change .js_raw_price': '_computePriceTotal'
+ }),
+ /**
+ * Initializes the optional products modal
+ *
+ * @override
+ * @param {$.Element} parent The parent container
+ * @param {Object} params
+ * @param {integer} params.pricelistId
+ * @param {string} params.okButtonText The text to apply on the "ok" button, typically
+ * "Add" for the sale order and "Proceed to checkout" on the web shop
+ * @param {string} params.cancelButtonText same as "params.okButtonText" but
+ * for the cancel button
+ * @param {integer} params.previousModalHeight used to configure a min height on the modal-content.
+ * This parameter is provided by the product configurator to "cover" its modal by making
+ * this one big enough. This way the user can't see multiple buttons (which can be confusing).
+ * @param {Object} params.rootProduct The root product of the optional products window
+ * @param {integer} params.rootProduct.product_id
+ * @param {integer} params.rootProduct.quantity
+ * @param {Array} params.rootProduct.variant_values
+ * @param {Array} params.rootProduct.product_custom_attribute_values
+ * @param {Array} params.rootProduct.no_variant_attribute_values
+ */
+ init: function (parent, params) {
+ var self = this;
+
+ var options = _.extend({
+ size: 'large',
+ buttons: [{
+ text: params.okButtonText,
+ click: this._onConfirmButtonClick,
+ classes: 'btn-primary'
+ }, {
+ text: params.cancelButtonText,
+ click: this._onCancelButtonClick
+ }],
+ technical: !params.isWebsite,
+ }, params || {});
+
+ this._super(parent, options);
+
+ this.context = params.context;
+ this.rootProduct = params.rootProduct;
+ this.container = parent;
+ this.pricelistId = params.pricelistId;
+ this.previousModalHeight = params.previousModalHeight;
+ this.dialogClass = 'oe_optional_products_modal';
+ this._productImageField = 'image_128';
+
+ this._opened.then(function () {
+ if (self.previousModalHeight) {
+ self.$el.closest('.modal-content').css('min-height', self.previousModalHeight + 'px');
+ }
+ });
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+
+ var uri = this._getUri("/sale_product_configurator/show_optional_products");
+ var getModalContent = ajax.jsonRpc(uri, 'call', {
+ product_id: self.rootProduct.product_id,
+ variant_values: self.rootProduct.variant_values,
+ pricelist_id: self.pricelistId || false,
+ add_qty: self.rootProduct.quantity,
+ kwargs: {
+ context: _.extend({
+ 'quantity': self.rootProduct.quantity
+ }, this.context),
+ }
+ })
+ .then(function (modalContent) {
+ if (modalContent) {
+ var $modalContent = $(modalContent);
+ $modalContent = self._postProcessContent($modalContent);
+ self.$content = $modalContent;
+ } else {
+ self.trigger('options_empty');
+ self.preventOpening = true;
+ }
+ });
+
+ var parentInit = self._super.apply(self, arguments);
+ return Promise.all([getModalContent, parentInit]);
+ },
+
+ /**
+ * This is overridden to append the modal to the provided container (see init("parent")).
+ * We need this to have the modal contained in the web shop product form.
+ * The additional products data will then be contained in the form and sent on submit.
+ *
+ * @override
+ */
+ open: function (options) {
+ $('.tooltip').remove(); // remove open tooltip if any to prevent them staying when modal is opened
+
+ var self = this;
+ this.appendTo($('<div/>')).then(function () {
+ if (!self.preventOpening) {
+ self.$modal.find(".modal-body").replaceWith(self.$el);
+ self.$modal.attr('open', true);
+ self.$modal.removeAttr("aria-hidden");
+ self.$modal.modal().appendTo(self.container);
+ self.$modal.focus();
+ self._openedResolver();
+
+ // Notifies OwlDialog to adjust focus/active properties on owl dialogs
+ OwlDialog.display(self);
+ }
+ });
+ if (options && options.shouldFocusButtons) {
+ self._onFocusControlButton();
+ }
+
+ return self;
+ },
+ /**
+ * Will update quantity input to synchronize with previous window
+ *
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+ var self = this;
+
+ this.$el.find('input[name="add_qty"]').val(this.rootProduct.quantity);
+
+ // set a unique id to each row for options hierarchy
+ var $products = this.$el.find('tr.js_product');
+ _.each($products, function (el) {
+ var $el = $(el);
+ var uniqueId = self._getUniqueId(el);
+
+ var productId = parseInt($el.find('input.product_id').val(), 10);
+ if (productId === self.rootProduct.product_id) {
+ self.rootProduct.unique_id = uniqueId;
+ } else {
+ el.dataset.parentUniqueId = self.rootProduct.unique_id;
+ }
+ });
+
+ return def.then(function () {
+ // This has to be triggered to compute the "out of stock" feature
+ self._opened.then(function () {
+ self.triggerVariantChange(self.$el);
+ });
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns the list of selected products.
+ * The root product is added on top of the list.
+ *
+ * @returns {Array} products
+ * {integer} product_id
+ * {integer} quantity
+ * {Array} product_custom_variant_values
+ * {Array} no_variant_attribute_values
+ * @public
+ */
+ getSelectedProducts: function () {
+ var self = this;
+ var products = [this.rootProduct];
+ this.$modal.find('.js_product.in_cart:not(.main_product)').each(function () {
+ var $item = $(this);
+ var quantity = parseInt($item.find('input[name="add_qty"]').val(), 10);
+ var parentUniqueId = this.dataset.parentUniqueId;
+ var uniqueId = this.dataset.uniqueId;
+ var productCustomVariantValues = self.getCustomVariantValues($(this));
+ var noVariantAttributeValues = self.getNoVariantAttributeValues($(this));
+ products.push({
+ 'product_id': parseInt($item.find('input.product_id').val(), 10),
+ 'product_template_id': parseInt($item.find('input.product_template_id').val(), 10),
+ 'quantity': quantity,
+ 'parent_unique_id': parentUniqueId,
+ 'unique_id': uniqueId,
+ 'product_custom_attribute_values': productCustomVariantValues,
+ 'no_variant_attribute_values': noVariantAttributeValues
+ });
+ });
+
+ return products;
+ },
+
+ // ------------------------------------------
+ // Private
+ // ------------------------------------------
+
+ /**
+ * Adds the product image and updates the product description
+ * based on attribute values that are either "no variant" or "custom".
+ *
+ * @private
+ */
+ _postProcessContent: function ($modalContent) {
+ var productId = this.rootProduct.product_id;
+ $modalContent
+ .find('img:first')
+ .attr("src", "/web/image/product.product/" + productId + "/image_128");
+
+ if (this.rootProduct &&
+ (this.rootProduct.product_custom_attribute_values ||
+ this.rootProduct.no_variant_attribute_values)) {
+ var $productDescription = $modalContent
+ .find('.main_product')
+ .find('td.td-product_name div.text-muted.small > div:first');
+ var $updatedDescription = $('<div/>');
+ $updatedDescription.append($('<p>', {
+ text: $productDescription.text()
+ }));
+
+ $.each(this.rootProduct.product_custom_attribute_values, function (){
+ $updatedDescription.append($('<div>', {
+ text: this.attribute_value_name + ': ' + this.custom_value
+ }));
+ });
+
+ $.each(this.rootProduct.no_variant_attribute_values, function (){
+ if (this.is_custom !== 'True'){
+ $updatedDescription.append($('<div>', {
+ text: this.attribute_name + ': ' + this.attribute_value_name
+ }));
+ }
+ });
+
+ $productDescription.replaceWith($updatedDescription);
+ }
+
+ return $modalContent;
+ },
+
+ /**
+ * @private
+ */
+ _onConfirmButtonClick: function () {
+ this.trigger('confirm');
+ this.close();
+ },
+
+ /**
+ * @private
+ */
+ _onCancelButtonClick: function () {
+ this.trigger('back');
+ this.close();
+ },
+
+ /**
+ * Will add/remove the option, that includes:
+ * - Moving it to the correct DOM section
+ * and possibly under its parent product
+ * - Hiding attribute values selection and showing the quantity
+ * - Creating the product if it's in "dynamic" mode (see product_attribute.create_variant)
+ * - Updating the description based on custom/no_create attribute values
+ * - Removing optional products if parent product is removed
+ * - Computing the total price
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddOrRemoveOption: function (ev) {
+ ev.preventDefault();
+ var self = this;
+ var $target = $(ev.currentTarget);
+ var $modal = $target.parents('.oe_optional_products_modal');
+ var $parent = $target.parents('.js_product:first');
+ $parent.find("a.js_add, span.js_remove").toggleClass('d-none');
+ $parent.find(".js_remove");
+
+ var productTemplateId = $parent.find(".product_template_id").val();
+ if ($target.hasClass('js_add')) {
+ self._onAddOption($modal, $parent, productTemplateId);
+ } else {
+ self._onRemoveOption($modal, $parent);
+ }
+
+ self._computePriceTotal();
+ },
+
+ /**
+ * @private
+ * @see _onAddOrRemoveOption
+ * @param {$.Element} $modal
+ * @param {$.Element} $parent
+ * @param {integer} productTemplateId
+ */
+ _onAddOption: function ($modal, $parent, productTemplateId) {
+ var self = this;
+ var $selectOptionsText = $modal.find('.o_select_options');
+
+ var parentUniqueId = $parent[0].dataset.parentUniqueId;
+ var $optionParent = $modal.find('tr.js_product[data-unique-id="' + parentUniqueId + '"]');
+
+ // remove attribute values selection and update + show quantity input
+ $parent.find('.td-product_name').removeAttr("colspan");
+ $parent.find('.td-qty').removeClass('d-none');
+
+ var productCustomVariantValues = self.getCustomVariantValues($parent);
+ var noVariantAttributeValues = self.getNoVariantAttributeValues($parent);
+ if (productCustomVariantValues || noVariantAttributeValues) {
+ var $productDescription = $parent
+ .find('td.td-product_name div.float-left');
+
+ var $customAttributeValuesDescription = $('<div>', {
+ class: 'custom_attribute_values_description text-muted small'
+ });
+ if (productCustomVariantValues.length !== 0 || noVariantAttributeValues.length !== 0) {
+ $customAttributeValuesDescription.append($('<br/>'));
+ }
+
+ $.each(productCustomVariantValues, function (){
+ $customAttributeValuesDescription.append($('<div>', {
+ text: this.attribute_value_name + ': ' + this.custom_value
+ }));
+ });
+
+ $.each(noVariantAttributeValues, function (){
+ if (this.is_custom !== 'True'){
+ $customAttributeValuesDescription.append($('<div>', {
+ text: this.attribute_name + ': ' + this.attribute_value_name
+ }));
+ }
+ });
+
+ $productDescription.append($customAttributeValuesDescription);
+ }
+
+ // place it after its parent and its parent options
+ var $tmpOptionParent = $optionParent;
+ while ($tmpOptionParent.length) {
+ $optionParent = $tmpOptionParent;
+ $tmpOptionParent = $modal.find('tr.js_product.in_cart[data-parent-unique-id="' + $optionParent[0].dataset.uniqueId + '"]').last();
+ }
+ $optionParent.after($parent);
+ $parent.addClass('in_cart');
+
+ this.selectOrCreateProduct(
+ $parent,
+ $parent.find('.product_id').val(),
+ productTemplateId,
+ true
+ ).then(function (productId) {
+ $parent.find('.product_id').val(productId);
+
+ ajax.jsonRpc(self._getUri("/sale_product_configurator/optional_product_items"), 'call', {
+ 'product_id': productId,
+ 'pricelist_id': self.pricelistId || false,
+ }).then(function (addedItem) {
+ var $addedItem = $(addedItem);
+ $modal.find('tr:last').after($addedItem);
+
+ self.$el.find('input[name="add_qty"]').trigger('change');
+ self.triggerVariantChange($addedItem);
+
+ // add a unique id to the new products
+ var parentUniqueId = $parent[0].dataset.uniqueId;
+ var parentQty = $parent.find('input[name="add_qty"]').val();
+ $addedItem.filter('.js_product').each(function () {
+ var $el = $(this);
+ var uniqueId = self._getUniqueId(this);
+ this.dataset.uniqueId = uniqueId;
+ this.dataset.parentUniqueId = parentUniqueId;
+ $el.find('input[name="add_qty"]').val(parentQty);
+ });
+
+ if ($selectOptionsText.nextAll('.js_product').length === 0) {
+ // no more optional products to select -> hide the header
+ $selectOptionsText.hide();
+ }
+ });
+ });
+ },
+
+ /**
+ * @private
+ * @see _onAddOrRemoveOption
+ * @param {$.Element} $modal
+ * @param {$.Element} $parent
+ */
+ _onRemoveOption: function ($modal, $parent) {
+ // restore attribute values selection
+ var uniqueId = $parent[0].dataset.parentUniqueId;
+ var qty = $modal.find('tr.js_product.in_cart[data-unique-id="' + uniqueId + '"]').find('input[name="add_qty"]').val();
+ $parent.removeClass('in_cart');
+ $parent.find('.td-product_name').attr("colspan", 2);
+ $parent.find('.td-qty').addClass('d-none');
+ $parent.find('input[name="add_qty"]').val(qty);
+ $parent.find('.custom_attribute_values_description').remove();
+
+ $modal.find('.o_select_options').show();
+
+ var productUniqueId = $parent[0].dataset.uniqueId;
+ this._removeOptionOption($modal, productUniqueId);
+
+ $modal.find('tr:last').after($parent);
+ },
+
+ /**
+ * If the removed product had optional products, remove them as well
+ *
+ * @private
+ * @param {$.Element} $modal
+ * @param {integer} optionUniqueId The removed optional product id
+ */
+ _removeOptionOption: function ($modal, optionUniqueId) {
+ var self = this;
+ $modal.find('tr.js_product[data-parent-unique-id="' + optionUniqueId + '"]').each(function () {
+ var uniqueId = this.dataset.uniqueId;
+ $(this).remove();
+ self._removeOptionOption($modal, uniqueId);
+ });
+ },
+ /**
+ * @override
+ */
+ _onChangeCombination: function (ev, $parent, combination) {
+ $parent
+ .find('.td-product_name .product-name')
+ .first()
+ .text(combination.display_name);
+
+ VariantMixin._onChangeCombination.apply(this, arguments);
+
+ this._computePriceTotal();
+ },
+ /**
+ * Update price total when the quantity of a product is changed
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onChangeQuantity: function (ev) {
+ var $product = $(ev.target.closest('tr.js_product'));
+ var qty = parseFloat($(ev.currentTarget).val());
+
+ var uniqueId = $product[0].dataset.uniqueId;
+ this.$el.find('tr.js_product:not(.in_cart)[data-parent-unique-id="' + uniqueId + '"] input[name="add_qty"]').each(function () {
+ $(this).val(qty);
+ });
+
+ if (this._triggerPriceUpdateOnChangeQuantity()) {
+ this.onChangeAddQuantity(ev);
+ }
+ if ($product.hasClass('main_product')) {
+ this.rootProduct.quantity = qty;
+ }
+ this.trigger('update_quantity', this.rootProduct.quantity);
+ this._computePriceTotal();
+ },
+
+ /**
+ * When a product is added or when the quantity is changed,
+ * we need to refresh the total price row
+ */
+ _computePriceTotal: function () {
+ if (this.$modal.find('.js_price_total').length) {
+ var price = 0;
+ this.$modal.find('.js_product.in_cart').each(function () {
+ var quantity = parseInt($(this).find('input[name="add_qty"]').first().val(), 10);
+ price += parseFloat($(this).find('.js_raw_price').html()) * quantity;
+ });
+
+ this.$modal.find('.js_price_total .oe_currency_value').text(
+ this._priceToStr(parseFloat(price))
+ );
+ }
+ },
+
+ /**
+ * Extension point for website_sale
+ *
+ * @private
+ */
+ _triggerPriceUpdateOnChangeQuantity: function () {
+ return true;
+ },
+ /**
+ * Returns a unique id for `$el`.
+ *
+ * @private
+ * @param {Element} el
+ * @returns {integer}
+ */
+ _getUniqueId: function (el) {
+ if (!el.dataset.uniqueId) {
+ el.dataset.uniqueId = parseInt(_.uniqueId(), 10);
+ }
+ return el.dataset.uniqueId;
+ },
+});
+
+return OptionalProductsModal;
+
+});
diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js b/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js
new file mode 100644
index 00000000..f0009e42
--- /dev/null
+++ b/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js
@@ -0,0 +1,133 @@
+odoo.define('sale_product_configurator.ProductConfiguratorFormRenderer', function (require) {
+"use strict";
+
+var FormRenderer = require('web.FormRenderer');
+var VariantMixin = require('sale.VariantMixin');
+
+var ProductConfiguratorFormRenderer = FormRenderer.extend(VariantMixin, {
+
+ events: _.extend({}, FormRenderer.prototype.events, VariantMixin.events, {
+ 'click button.js_add_cart_json': 'onClickAddCartJSON',
+ }),
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.pricelistId = this.state.context.default_pricelist_id || 0;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self.$el.append($('<div>', {class: 'configurator_container'}));
+ self.renderConfigurator(self.configuratorHtml);
+ self._checkMode();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Renders the product configurator within the form
+ *
+ * Will also:
+ *
+ * - add events handling for variant changes
+ * - trigger variant change to compute the price and other
+ * variant specific changes
+ *
+ * @param {string} configuratorHtml the evaluated template of
+ * the product configurator
+ */
+ renderConfigurator: function (configuratorHtml) {
+ var $configuratorContainer = this.$('.configurator_container');
+ $configuratorContainer.empty();
+
+ var $configuratorHtml = $(configuratorHtml);
+ $configuratorHtml.appendTo($configuratorContainer);
+
+ this.triggerVariantChange($configuratorContainer);
+ this._applyCustomValues();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * If the configuratorMode in the given context is 'edit', we need to
+ * hide the regular 'Add' button to replace it with an 'EDIT' button.
+ *
+ * If the configuratorMode is set to 'options', we will directly open the
+ * options modal.
+ *
+ * @private
+ */
+ _checkMode: function () {
+ if (this.state.context.configuratorMode === 'edit') {
+ this.$('.o_sale_product_configurator_add').hide();
+ this.$('.o_sale_product_configurator_edit').css('display', 'inline-block');
+ } else if (this.state.context.configuratorMode === 'options') {
+ this.trigger_up('handle_add');
+ }
+ },
+
+ /**
+ * Toggles the add button depending on the possibility of the current
+ * combination.
+ *
+ * @override
+ */
+ _toggleDisable: function ($parent, isCombinationPossible) {
+ VariantMixin._toggleDisable.apply(this, arguments);
+ $parent.parents('.modal').find('.o_sale_product_configurator_add').toggleClass('disabled', !isCombinationPossible);
+ },
+
+ /**
+ * Will fill the custom values input based on the provided initial configuration.
+ *
+ * @private
+ */
+ _applyCustomValues: function () {
+ var self = this;
+ var customValueIds = this.state.data.product_custom_attribute_value_ids;
+ if (customValueIds) {
+ _.each(customValueIds.data, function (customValue) {
+ if (customValue.data.custom_value) {
+ var attributeValueId = customValue.data.custom_product_template_attribute_value_id.data.id;
+ var $input = self._findRelatedAttributeValueInput(attributeValueId);
+ $input
+ .closest('li[data-attribute_id]')
+ .find('.variant_custom_value')
+ .val(customValue.data.custom_value);
+ }
+ });
+ }
+ },
+
+ /**
+ * Find the $.Element input/select related to that product.attribute.value
+ *
+ * @param {integer} attributeValueId
+ *
+ * @private
+ */
+ _findRelatedAttributeValueInput: function (attributeValueId) {
+ var selectors = [
+ 'ul.js_add_cart_variants input[data-value_id="' + attributeValueId + '"]',
+ 'ul.js_add_cart_variants option[data-value_id="' + attributeValueId + '"]'
+ ];
+
+ return this.$(selectors.join(', '));
+ }
+});
+
+return ProductConfiguratorFormRenderer;
+
+});
diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_view.js b/addons/sale_product_configurator/static/src/js/product_configurator_view.js
new file mode 100644
index 00000000..aa16a0bb
--- /dev/null
+++ b/addons/sale_product_configurator/static/src/js/product_configurator_view.js
@@ -0,0 +1,20 @@
+odoo.define('sale_product_configurator.ProductConfiguratorFormView', function (require) {
+"use strict";
+
+var ProductConfiguratorFormController = require('sale_product_configurator.ProductConfiguratorFormController');
+var ProductConfiguratorFormRenderer = require('sale_product_configurator.ProductConfiguratorFormRenderer');
+var FormView = require('web.FormView');
+var viewRegistry = require('web.view_registry');
+
+var ProductConfiguratorFormView = FormView.extend({
+ config: _.extend({}, FormView.prototype.config, {
+ Controller: ProductConfiguratorFormController,
+ Renderer: ProductConfiguratorFormRenderer,
+ }),
+});
+
+viewRegistry.add('product_configurator_form', ProductConfiguratorFormView);
+
+return ProductConfiguratorFormView;
+
+});
diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_widget.js b/addons/sale_product_configurator/static/src/js/product_configurator_widget.js
new file mode 100644
index 00000000..9132eb01
--- /dev/null
+++ b/addons/sale_product_configurator/static/src/js/product_configurator_widget.js
@@ -0,0 +1,378 @@
+odoo.define('sale_product_configurator.product_configurator', function (require) {
+var ProductConfiguratorWidget = require('sale.product_configurator');
+
+/**
+ * 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',
+ default_pricelist_id: this._getPricelistId(),
+ default_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
+ */
+ _openProductConfigurator: function (data, dataPointId) {
+ this.optionalProducts = undefined;
+ var self = this;
+ this.do_action('sale_product_configurator.sale_product_configurator_action', {
+ additional_context: data,
+ on_close: function (result) {
+ if (result && !result.special) {
+ self._addProducts(result, dataPointId);
+ } else {
+ if (self.restoreProductTemplateId) {
+ // if configurator opened in edit mode.
+ self.trigger_up('field_changed', {
+ dataPointID: dataPointId,
+ preventProductIdCheck: true,
+ changes: {
+ product_template_id: self.restoreProductTemplateId.data
+ }
+ });
+ } else {
+ // if configurator opened to create line:
+ // destroy line if configurator closed during configuration process.
+ self.trigger_up('field_changed', {
+ dataPointID: dataPointId,
+ changes: {
+ product_template_id: false,
+ product_id: false,
+ },
+ });
+ }
+ }
+ }
+ });
+ },
+
+ /**
+ * 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;
+ }
+ // If line has been set up through the product_configurator:
+ this._openProductConfigurator({
+ configuratorMode: 'edit',
+ default_product_template_id: this.recordData.product_template_id.data.id,
+ default_pricelist_id: this._getPricelistId(),
+ default_product_template_attribute_value_ids: this._convertFromMany2Many(
+ this.recordData.product_template_attribute_value_ids
+ ),
+ default_product_no_variant_attribute_value_ids: this._convertFromMany2Many(
+ this.recordData.product_no_variant_attribute_value_ids
+ ),
+ default_product_custom_attribute_value_ids: this._convertFromOne2Many(
+ this.recordData.product_custom_attribute_value_ids
+ ),
+ default_quantity: this.recordData.product_uom_qty
+ },
+ this.dataPointID
+ );
+ },
+
+ /**
+ * This will first modify the SO line to update all the information coming from
+ * the product configurator using the 'field_changed' event.
+ *
+ * onSuccess from that first method, it will add the optional products to the SO
+ * using the 'add_record' event.
+ *
+ * Doing both at the same time could lead to unordered product_template/options.
+ *
+ * @param {Object} products the products to add to the SO line.
+ * {Object} products.mainProduct the product_template configured
+ * with various attribute/custom values
+ * {Array} products.options the various selected optional products
+ * with their configuration
+ * @param {string} dataPointId
+ *
+ * @private
+ */
+ _addProducts: function (result, dataPointId) {
+ 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;
+ }
+});
+
+return ProductConfiguratorWidget;
+
+});
diff --git a/addons/sale_product_configurator/static/tests/product_configurator.test.js b/addons/sale_product_configurator/static/tests/product_configurator.test.js
new file mode 100644
index 00000000..2d737820
--- /dev/null
+++ b/addons/sale_product_configurator/static/tests/product_configurator.test.js
@@ -0,0 +1,309 @@
+odoo.define('sale.product.configurator.tests', function (require) {
+"use strict";
+
+var FormView = require('web.FormView');
+var ProductConfiguratorFormView = require('sale_product_configurator.ProductConfiguratorFormView');
+var testUtils = require('web.test_utils');
+var createView = testUtils.createView;
+
+var 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);
+
+ var form = await createView({
+ View: FormView,
+ model: 'sale_order',
+ data: this.data,
+ arch: getArch(),
+ mockRPC: function (route, params) {
+ if (params.method === 'get_single_product_variant') {
+ assert.ok(true);
+ return Promise.resolve({product_id: 2});
+ }
+ // FIXME awa: this shouldn't be here since the read is done in 'event_sale'
+ // But at the moment there is no easy way to solve such cross module 'include' issues
+ if (params.method === 'read') {
+ return Promise.resolve(false);
+ }
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ do_action: function (ev) {
+ if (ev.data.action === 'sale_product_configurator.sale_product_configurator_action') {
+ assert.ok(false, "Should not execute the configure action");
+ }
+ },
+ }
+ });
+
+ await testUtils.dom.click(form.$("a:contains('Add a product')"));
+ await testUtils.fields.many2one.searchAndClickItem("product_template_id", {item: 'Customizable Desk'});
+ // check that product_id is correctly set to 2
+ assert.strictEqual(form.renderer.state.data.sale_order_line.data[0].data.product_id.data.id, 2);
+ form.destroy();
+ });
+
+ QUnit.test('Select a configurable product template and verify that the product configurator is opened', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'sale_order',
+ data: this.data,
+ arch: getArch(),
+ mockRPC: function (route, params) {
+ if (params.method === 'get_single_product_variant') {
+ assert.ok(true);
+ return Promise.resolve(false);
+ }
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ do_action: function (ev) {
+ if (ev.data.action === 'sale_product_configurator.sale_product_configurator_action') {
+ assert.ok(true);
+ }
+ },
+ }
+ });
+
+ await testUtils.dom.click(form.$("a:contains('Add a product')"));
+ await testUtils.fields.many2one.searchAndClickItem("product_template_id", {item: 'Customizable Desk'});
+ form.destroy();
+ });
+
+ QUnit.test('trigger_up the "add_record" event and checks that rows are correctly added to the list', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'sale_order',
+ data: this.data,
+ arch: getArch()
+ });
+
+ var list = form.renderer.allFieldWidgets[form.handle][1];
+
+ list.trigger_up('add_record', {
+ context: [{default_product_id: 1, default_product_uom_qty: 2}, {default_product_id: 2, default_product_uom_qty: 3}],
+ forceEditable: "bottom" ,
+ allowWarning: true
+ });
+ await testUtils.nextTick();
+
+ assert.containsN(list, "tr.o_data_row", 2);
+ form.destroy();
+ });
+
+ QUnit.test('Select a product in the list and check for template loading', async function (assert) {
+ assert.expect(1);
+
+ var product_configurator_form = await createView({
+ View: ProductConfiguratorFormView,
+ model: 'sale_product_configurator',
+ data: this.data,
+ arch:
+ '<form js_class="product_configurator_form">' +
+ '<group>' +
+ '<field name="product_template_id" class="oe_product_configurator_product_template_id" />' +
+ '<field name="product_template_attribute_value_ids" invisible="1" />' +
+ '<field name="product_no_variant_attribute_value_ids" invisible="1" />' +
+ '<field name="product_custom_attribute_value_ids" invisible="1" />' +
+ '</group>' +
+ '<footer>' +
+ '<button string="Add" class="btn-primary o_sale_product_configurator_add disabled"/>' +
+ '<button string="Cancel" class="btn-secondary" special="cancel"/>' +
+ '</footer>' +
+ '</form>',
+ mockRPC: function (route) {
+ if (route === '/sale_product_configurator/configure') {
+ assert.ok(true);
+ return Promise.resolve('<div>plop</div>');
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+ await testUtils.dom.click(product_configurator_form.$('.o_input'));
+ await testUtils.dom.click($("ul.ui-autocomplete li a:contains('Customizable Desk')").mouseenter());
+ product_configurator_form.destroy();
+ });
+
+ 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);
+
+ assert.strictEqual(form.$('.o_data_row').text(), 'Customizable Desk (2)Customizable Desk (1)');
+
+ form.destroy();
+ });
+});
+
+});
diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_advanced_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_advanced_ui.js
new file mode 100644
index 00000000..16251f56
--- /dev/null
+++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_advanced_ui.js
@@ -0,0 +1,165 @@
+odoo.define('sale.sale_product_configurator_advanced_tour', function (require) {
+"use strict";
+
+var tour = require('web_tour.tour');
+
+var optionVariantImage;
+
+tour.register('sale_product_configurator_advanced_tour', {
+ url: "/web",
+ test: true,
+}, [tour.stepUtils.showAppsMenuItem(), {
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]', // Note: The module sale_management is mandatory
+ edition: 'community'
+}, {
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
+ edition: 'enterprise'
+}, {
+ trigger: ".o_list_button_add",
+ extra_trigger: ".o_sale_order"
+}, {
+ trigger: ".o_required_modifier[name=partner_id] input",
+ run: "text Tajine Saucisse",
+}, {
+ trigger: ".ui-menu-item > a:contains('Tajine Saucisse')",
+ auto: true,
+}, {
+ trigger: "a:contains('Add a product')",
+ extra_trigger: ".o_field_widget[name=partner_shipping_id] > .o_external_button", // Wait for onchange_partner_id
+}, {
+ trigger: 'div[name="product_template_id"] input',
+ run: function (){
+ var $input = $('div[name="product_template_id"] input');
+ $input.click();
+ $input.val('Custo');
+ var keyDownEvent = jQuery.Event("keydown");
+ keyDownEvent.which = 42;
+ $input.trigger(keyDownEvent);
+ }
+}, {
+ trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")',
+ run: 'click'
+}, {
+ trigger: 'span:contains("Custom")',
+ extra_trigger: '.o_product_configurator',
+ run: 'click'
+}, {
+ trigger: '.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(1) .variant_custom_value',
+ extra_trigger: '.o_product_configurator',
+ run: 'text Custom 1'
+}, {
+ trigger: '.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(3) span:contains("PAV9")',
+ extra_trigger: '.o_product_configurator',
+ run: 'click'
+}, {
+ trigger: '.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(3) .variant_custom_value',
+ extra_trigger: '.o_product_configurator',
+ run: 'text Custom 2'
+}, {
+ trigger: '.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(4) span:contains("PAV5")',
+ extra_trigger: '.o_product_configurator',
+ run: 'click'
+}, {
+ trigger: '.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select ',
+ extra_trigger: '.o_product_configurator',
+ run: function (){
+ var inputValue = $('.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) option[data-value_name="PAV9"]').val();
+ $('.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select').val(inputValue);
+ $('.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select').trigger('change');
+ }
+}, {
+ trigger: '.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) .variant_custom_value',
+ extra_trigger: '.o_product_configurator',
+ run: 'text Custom 3'
+}, {
+ trigger: ".o_sale_product_configurator_add",
+ run: 'click'
+}, {
+ trigger: '.main_product strong:contains("Custom, White, PAV9, PAV5, PAV1")',
+ extra_trigger: '.oe_optional_products_modal',
+ run: function () {} //check
+}, {
+ trigger: '.main_product div:contains("Custom: Custom 1")',
+ extra_trigger: '.oe_optional_products_modal',
+ run: function () {} //check
+}, {
+ trigger: '.main_product div:contains("PAV9: Custom 2")',
+ extra_trigger: '.oe_optional_products_modal',
+ run: function () {} //check
+}, {
+ trigger: '.main_product div:contains("PAV9: Custom 3")',
+ extra_trigger: '.oe_optional_products_modal',
+ run: function () {} //check
+}, {
+ trigger: '.main_product div:contains("PA5: PAV1")',
+ extra_trigger: '.oe_optional_products_modal',
+ run: function () {} //check
+}, {
+ trigger: '.main_product div:contains("PA7: PAV1")',
+ extra_trigger: '.oe_optional_products_modal',
+ run: function () {} //check
+}, {
+ trigger: '.main_product div:contains("PA8: PAV1")',
+ extra_trigger: '.oe_optional_products_modal',
+ run: function () {} //check
+}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (TEST) (Steel)")',
+ run: function () {
+ optionVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
+ }
+}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
+}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (TEST) (Aluminium)")',
+ run: function () {
+ var newVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
+ if (newVariantImage !== optionVariantImage) {
+ $('<p>').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name');
+ }
+
+ }
+}, {
+ extra_trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("image variant option src changed")',
+ trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Steel"]',
+}, {
+ trigger: 'button span:contains(Confirm)',
+ extra_trigger: '.oe_optional_products_modal',
+ run: 'click'
+}, {
+ 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
+}]);
+
+});
diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_edition_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_edition_ui.js
new file mode 100644
index 00000000..dc7663d6
--- /dev/null
+++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_edition_ui.js
@@ -0,0 +1,160 @@
+odoo.define('sale.product_configurator_edition_tour', function (require) {
+"use strict";
+
+var tour = require('web_tour.tour');
+
+tour.register('sale_product_configurator_edition_tour', {
+ url: "/web",
+ test: true,
+}, [tour.stepUtils.showAppsMenuItem(), {
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
+ edition: 'community'
+}, {
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
+ edition: 'enterprise'
+}, {
+ trigger: ".o_list_button_add",
+ extra_trigger: ".o_sale_order"
+}, {
+ trigger: "a:contains('Add a product')"
+}, {
+ trigger: 'div[name="product_template_id"] input',
+ run: function (){
+ var $input = $('div[name="product_template_id"] input');
+ $input.click();
+ $input.val('Custo');
+ var keyDownEvent = jQuery.Event("keydown");
+ keyDownEvent.which = 42;
+ $input.trigger(keyDownEvent);
+ }
+}, {
+ trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")',
+ run: 'click'
+}, {
+ trigger: '.configurator_container span:contains("Steel")',
+ run: function () {
+ $('input.product_id').change(function () {
+ $('.o_sale_product_configurator_add').attr('request_count', 1);
+ });
+ }
+}, {
+ trigger: '.configurator_container span:contains("Aluminium")'
+}, {
+ trigger: '.o_sale_product_configurator_add[request_count="1"]',
+ run: function (){} // used to sync with "get_combination_info" completion
+}, {
+ trigger: '.o_sale_product_configurator_add:not(.disabled)'
+}, {
+ trigger: 'button span:contains(Confirm)',
+ extra_trigger: '.oe_optional_products_modal',
+ run: 'click'
+}, {
+ trigger: 'td.o_data_cell:contains("Customizable Desk (TEST) (Aluminium, White)")',
+ extra_trigger: 'div[name="order_line"]',
+ run: function (){} // check added product
+}, {
+ trigger: 'td.o_product_configurator_cell',
+}, {
+ trigger: '.o_edit_product_configuration',
+}, {
+ trigger: '.configurator_container li.js_attribute_value:has(span:contains("Aluminium")) input:checked',
+ run: function (){} // check updated legs
+}, {
+ trigger: 'span.oe_currency_value:contains("800")',
+ run: function (){} // check updated price
+}, {
+ trigger: '.configurator_container span:contains("Steel")',
+ run: function () {
+ $('input.product_id').change(function () {
+ if ($('.o_sale_product_configurator_edit').attr('request_count')) {
+ $('.o_sale_product_configurator_edit').attr('request_count',
+ parseInt($('.o_sale_product_configurator_edit').attr('request_count')) + 1);
+ } else {
+ $('.o_sale_product_configurator_edit').attr('request_count', 1);
+ }
+ });
+ }
+}, {
+ trigger: '.configurator_container span:contains("Custom")',
+ run: function () {
+ // FIXME awa: since jquery3 update it doesn't "click"
+ // on the element without this run (and 'run: "click"'
+ // doesn't work either)
+ $('.configurator_container span:contains("Custom")').click();
+ }
+}, {
+ trigger: '.configurator_container .variant_custom_value',
+ run: 'text nice custom value'
+}, {
+ trigger: 'input[data-value_name="Black"]',
+ run: 'click'
+}, {
+ trigger: '.o_sale_product_configurator_edit[request_count="2"]',
+ run: function (){} // used to sync with "get_combination_info" completion
+}, {
+ trigger: '.o_sale_product_configurator_edit',
+}, {
+ trigger: 'td.o_data_cell:contains("Customizable Desk (TEST) (Custom, Black)")',
+ extra_trigger: 'div[name="order_line"]',
+ run: function (){} // check updated product
+}, {
+ trigger: 'td.o_data_cell:contains("Custom: nice custom value")',
+ extra_trigger: 'div[name="order_line"]',
+ run: function (){} // check custom value
+}, {
+ trigger: 'td.o_product_configurator_cell',
+}, {
+ trigger: '.o_edit_product_configuration',
+}, {
+ trigger: '.configurator_container .variant_custom_value',
+ run: 'text another nice custom value'
+}, {
+ trigger: '.o_sale_product_configurator_edit',
+}, {
+ trigger: 'td.o_data_cell:contains("Custom: another nice custom value")',
+ extra_trigger: 'div[name="order_line"]',
+ run: function (){} // check custom value
+}, {
+ trigger: 'td.o_product_configurator_cell',
+}, {
+ trigger: '.o_edit_product_configuration',
+}, {
+ trigger: '.configurator_container span:contains("Steel")',
+ run: function () {
+ $('input.product_id').change(function () {
+ $('.o_sale_product_configurator_edit').attr('request_count', 1);
+ });
+ }
+}, {
+ trigger: '.configurator_container span:contains("Steel")',
+ run: function () {
+ // FIXME awa: since jquery3 update it doesn't "click"
+ // on the element without this run (and 'run: "click"'
+ // doesn't work either)
+ $('.configurator_container span:contains("Steel")').click();
+ }
+}, {
+ trigger: '.o_sale_product_configurator_edit[request_count="1"]',
+ run: function (){} // used to sync with "get_combination_info" completion
+}, {
+ trigger: '.configurator_container button.js_add_cart_json:has(.fa-plus)',
+}, {
+ trigger: '.o_sale_product_configurator_edit',
+}, {
+ trigger: 'td.o_data_cell:contains("2.00")',
+ run: function (){} // check quantity
+}, {
+ trigger: 'td.o_product_configurator_cell',
+ run: function () {
+ // used to check that the description does not contain a custom value anymore
+ if ($('td.o_data_cell:contains("Custom: another nice custom value")').length === 0){
+ $('td.o_data_cell:contains("Customizable Desk (TEST) (Steel, Black)")').html('tour success');
+ }
+ }
+}, {
+ trigger: 'td.o_data_cell:contains("tour success")',
+ extra_trigger: 'div[name="order_line"]',
+ run: function (){}
+}]);
+
+});
diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_optional_products_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_optional_products_ui.js
new file mode 100644
index 00000000..c566ce5b
--- /dev/null
+++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_optional_products_ui.js
@@ -0,0 +1,79 @@
+odoo.define('sale.product_configurator_optional_products_tour', function (require) {
+"use strict";
+
+var tour = require('web_tour.tour');
+
+tour.register('sale_product_configurator_optional_products_tour', {
+ url: "/web",
+ test: true,
+}, [tour.stepUtils.showAppsMenuItem(), {
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
+ edition: 'community'
+}, {
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
+ edition: 'enterprise'
+}, {
+ trigger: ".o_list_button_add",
+ extra_trigger: ".o_sale_order"
+}, {
+ trigger: "a:contains('Add a product')"
+}, {
+ trigger: 'div[name="product_template_id"] input',
+ run: function () {
+ var $input = $('div[name="product_template_id"] input');
+ $input.click();
+ $input.val('Customizable Desk');
+ var keyDownEvent = jQuery.Event("keydown");
+ keyDownEvent.which = 42;
+ $input.trigger(keyDownEvent);
+ }
+}, {
+ trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")',
+ run: 'click'
+}, {
+ trigger: '.o_sale_product_configurator_add'
+}, {
+ trigger: 'tr:has(.td-product_name:contains("Office Chair Black")) .js_add',
+}, {
+ trigger: 'tr:has(.td-product_name:contains("Customizable Desk")) .fa-plus'
+}, {
+ trigger: 'tr:has(.td-product_name:contains("Chair floor protection")) .js_add',
+}, {
+ content: 'Is below its parent 1',
+ trigger: 'tr:has(.td-product_name:contains("Office Chair Black")) + tr:has(.td-product_name:contains("Chair floor protection"))'
+}, {
+ trigger: 'tr:has(.td-product_name:contains("Conference Chair")) .js_add',
+}, {
+ trigger: 'tr:has(.td-product_name:contains("Conference Chair")) .fa-minus'
+}, {
+ trigger: 'tr:has(.td-product_name:contains("Chair floor protection")) .js_add',
+}, {
+ content: 'Is below its parent 2',
+ trigger: 'tr:has(.td-product_name:contains("Conference Chair")) + tr:has(.td-product_name:contains("Chair floor protection"))'
+}, {
+ trigger: 'button span:contains(Confirm)',
+ extra_trigger: '.oe_optional_products_modal',
+ run: 'click'
+}, {
+ trigger: 'tr:has(td.o_data_cell:contains("Customizable Desk")) td.o_data_cell:contains("2.0")',
+ extra_trigger: 'div[name="order_line"]',
+ run: function () {}, // check added product
+}, {
+ trigger: 'tr:has(td.o_data_cell:contains("Office Chair Black")) td.o_data_cell:contains("1.0")',
+ extra_trigger: 'div[name="order_line"]',
+ run: function () {}, // check added product
+}, {
+ trigger: 'tr:has(td.o_data_cell:contains("Conference Chair")) td.o_data_cell:contains("1.0")',
+ extra_trigger: 'div[name="order_line"]',
+ run: function () {}, // check added product
+}, {
+ trigger: 'tr:has(td.o_data_cell:contains("Chair floor protection")):nth(0) td.o_data_cell:contains("1.0")',
+ extra_trigger: 'div[name="order_line"]',
+ run: function () {}, // check added product
+}, {
+ trigger: 'tr:has(td.o_data_cell:contains("Chair floor protection")):nth(1) td.o_data_cell:contains("1.0")',
+ extra_trigger: 'div[name="order_line"]',
+ run: function () {}, // check added product
+}]);
+
+});
diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_pricelist_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_pricelist_ui.js
new file mode 100644
index 00000000..af97bf1f
--- /dev/null
+++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_pricelist_ui.js
@@ -0,0 +1,99 @@
+odoo.define('sale.product_configurator_pricelist_tour', function (require) {
+"use strict";
+
+var tour = require('web_tour.tour');
+
+tour.register('sale_product_configurator_pricelist_tour', {
+ url: "/web",
+ test: true,
+},
+[
+tour.stepUtils.showAppsMenuItem(),
+{
+ content: "navigate to the sale app",
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
+ edition: 'community'
+}, {
+ content: "navigate to the sale app",
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
+ edition: 'enterprise'
+}, {
+ content: "create a new order",
+ trigger: '.o_list_button_add',
+ extra_trigger: ".o_sale_order"
+}, {
+ 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)',
+}, {
+ content: "search the pricelist",
+ trigger: 'div[name="pricelist_id"] input',
+ run: 'text Custom pricelist (TEST)'
+}, {
+ content: "select the pricelist",
+ trigger: 'ul.ui-autocomplete > li > a:contains(Custom pricelist (TEST))',
+}, {
+ trigger: "a:contains('Add a product')"
+}, {
+ trigger: 'div[name="product_template_id"] input',
+ run: function (){
+ var $input = $('div[name="product_template_id"] input');
+ $input.click();
+ $input.val('Custo');
+ var keyDownEvent = jQuery.Event("keydown");
+ keyDownEvent.which = 42;
+ $input.trigger(keyDownEvent);
+ }
+}, {
+ trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")',
+ run: 'click'
+}, {
+ content: "check price is correct (USD)",
+ trigger: 'span.oe_currency_value:contains("750.00")',
+ run: function () {} // check price
+}, {
+ content: "add one more",
+ trigger: 'button.js_add_cart_json:has(i.fa-plus)',
+}, {
+ content: "check price for 2",
+ trigger: 'span.oe_currency_value:contains("600.00")',
+ run: function () {} // check price (pricelist has discount for 2)
+}, {
+ content: "click add",
+ trigger: '.o_sale_product_configurator_add:not(.disabled)'
+}, {
+ content: "check we are on the add modal",
+ trigger: '.td-product_name:contains("Customizable Desk (TEST) (Steel, White)")',
+ extra_trigger: '.oe_optional_products_modal',
+ run: 'click'
+}, {
+ content: "add conference chair",
+ trigger: '.js_product:has(strong:contains(Conference Chair)) .js_add',
+ extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair))',
+ run: 'click'
+}, {
+ content: "add chair floor protection",
+ trigger: '.js_product:has(strong:contains(Chair floor protection)) .js_add',
+ extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection))',
+ run: 'click'
+}, {
+ content: "verify configurator final price", // tax excluded
+ trigger: '.o_total_row .oe_currency_value:contains("1,257.00")',
+}, {
+ content: "add to SO",
+ trigger: 'button span:contains(Confirm)',
+ extra_trigger: '.oe_optional_products_modal',
+ run: 'click'
+}, {
+ content: "verify SO final price excluded",
+ trigger: 'span[name="amount_untaxed"]:contains("1,257.00")',
+}, {
+ content: "verify SO final price included",
+ trigger: 'span[name="amount_total"]:contains("1,437.00")',
+}
+]);
+
+});
diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_single_custom_attribute_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_single_custom_attribute_ui.js
new file mode 100644
index 00000000..8b1db607
--- /dev/null
+++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_single_custom_attribute_ui.js
@@ -0,0 +1,79 @@
+odoo.define('sale.product_configurator_single_custom_attribute_tour', function (require) {
+"use strict";
+
+var tour = require('web_tour.tour');
+
+tour.register('sale_product_configurator_single_custom_attribute_tour', {
+ url: "/web",
+ test: true,
+}, [tour.stepUtils.showAppsMenuItem(), {
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
+ edition: 'community'
+}, {
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
+ edition: 'enterprise'
+}, {
+ trigger: ".o_list_button_add",
+ extra_trigger: ".o_sale_order"
+}, {
+ trigger: "a:contains('Add a product')"
+}, {
+ trigger: 'div[name="product_template_id"] input',
+ run: function (){
+ var $input = $('div[name="product_template_id"] input');
+ $input.click();
+ $input.val('Custo');
+ // fake keydown to trigger search
+ var keyDownEvent = jQuery.Event("keydown");
+ keyDownEvent.which = 42;
+ $input.trigger(keyDownEvent);
+ }
+}, {
+ trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")',
+ run: 'click'
+}, {
+ trigger: '.configurator_container span:contains("Aluminium")',
+ run: function () {
+ // used to check that the radio is NOT rendered
+ if ($('.configurator_container ul[data-attribute_id].d-none input[data-value_name="single product attribute value"]').length === 1) {
+ $('.configurator_container').addClass('tour_success');
+ }
+ }
+}, {
+ trigger: '.configurator_container.tour_success',
+ run: function () {
+ //check
+ }
+}, {
+ trigger: '.configurator_container .variant_custom_value',
+ run: 'text great single custom value'
+}, {
+ trigger: '.o_sale_product_configurator_add',
+}, {
+ trigger: 'button span:contains(Confirm)',
+ extra_trigger: '.oe_optional_products_modal',
+ run: 'click'
+}, {
+ trigger: 'td.o_data_cell:contains("single product attribute value: great single custom value")',
+ extra_trigger: 'div[name="order_line"]',
+ run: function (){} // check custom value
+}, {
+ trigger: 'td.o_product_configurator_cell',
+}, {
+ trigger: '.o_edit_product_configuration',
+}, {
+ trigger: '.configurator_container .variant_custom_value',
+ run: function () {
+ // check custom value initialized
+ if ($('.configurator_container .variant_custom_value').val() === "great single custom value") {
+ $('.configurator_container').addClass('tour_success_2');
+ }
+ }
+}, {
+ trigger: '.configurator_container.tour_success_2',
+ run: function () {
+ //check
+ }
+}]);
+
+});
diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_ui.js
new file mode 100644
index 00000000..fad1fbd3
--- /dev/null
+++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_ui.js
@@ -0,0 +1,95 @@
+odoo.define('sale.product_configurator_tour', function (require) {
+"use strict";
+
+var tour = require('web_tour.tour');
+
+// Note: please keep this test without pricelist for maximum coverage.
+// The pricelist is tested on the other tours.
+
+tour.register('sale_product_configurator_tour', {
+ url: "/web",
+ test: true,
+}, [tour.stepUtils.showAppsMenuItem(), {
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
+ edition: 'community'
+}, {
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
+ edition: 'enterprise'
+}, {
+ trigger: ".o_list_button_add",
+ extra_trigger: ".o_sale_order"
+}, {
+ trigger: "a:contains('Add a product')",
+}, {
+ trigger: 'div[name="product_template_id"] input',
+ run: function (){
+ var $input = $('div[name="product_template_id"] input');
+ $input.click();
+ $input.val('Custo');
+ // fake keydown to trigger search
+ var keyDownEvent = jQuery.Event("keydown");
+ keyDownEvent.which = 42;
+ $input.trigger(keyDownEvent);
+ }
+}, {
+ trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")',
+ run: 'click'
+}, {
+ trigger: '.configurator_container span:contains("Steel")',
+ run: function () {},
+}, {
+ trigger: '.configurator_container span:contains("Aluminium")',
+ run: 'click'
+}, {
+ trigger: 'span.oe_currency_value:contains("800.40")',
+ run: function (){} // check updated price
+}, {
+ trigger: 'input[data-value_name="Black"]'
+}, {
+ trigger: '.o_sale_product_configurator_add.disabled'
+}, {
+ trigger: 'input[data-value_name="White"]'
+}, {
+ trigger: '.o_sale_product_configurator_add:not(.disabled)'
+}, {
+ trigger: 'span:contains("Aluminium")',
+ extra_trigger: '.oe_optional_products_modal',
+ run: 'click'
+}, {
+ trigger: '.js_product:has(strong:contains(Conference Chair)) .js_add',
+ extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair))',
+ run: 'click'
+}, {
+ trigger: '.js_product:has(strong:contains(Chair floor protection)) .js_add',
+ extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection))',
+ run: 'click'
+}, {
+ trigger: 'button span:contains(Confirm)',
+ extra_trigger: '.oe_optional_products_modal',
+ id: "quotation_product_selected",
+ run: 'click'
+},
+// 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: '.o_readonly_modifier[name=amount_total]:contains("0.00")',
+ in_modal: false,
+ run: function (){}
+}
+]);
+
+});