diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/sale_product_configurator/static | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/sale_product_configurator/static')
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 (){} +} +]); + +}); |
