summaryrefslogtreecommitdiff
path: root/addons/sale_product_configurator/static/src
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/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/sale_product_configurator/static/src')
-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
5 files changed, 1358 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;
+
+});