diff options
Diffstat (limited to 'addons/sale/static/src/js/variant_mixin.js')
| -rw-r--r-- | addons/sale/static/src/js/variant_mixin.js | 637 |
1 files changed, 637 insertions, 0 deletions
diff --git a/addons/sale/static/src/js/variant_mixin.js b/addons/sale/static/src/js/variant_mixin.js new file mode 100644 index 00000000..3167be11 --- /dev/null +++ b/addons/sale/static/src/js/variant_mixin.js @@ -0,0 +1,637 @@ +odoo.define('sale.VariantMixin', function (require) { +'use strict'; + +var concurrency = require('web.concurrency'); +var core = require('web.core'); +var utils = require('web.utils'); +var ajax = require('web.ajax'); +var _t = core._t; + +var VariantMixin = { + events: { + 'change .css_attribute_color input': '_onChangeColorAttribute', + 'change .main_product:not(.in_cart) input.js_quantity': 'onChangeAddQuantity', + 'change [data-attribute_exclusions]': 'onChangeVariant' + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * When a variant is changed, this will check: + * - If the selected combination is available or not + * - The extra price if applicable + * - The display name of the product ("Customizable desk (White, Steel)") + * - The new total price + * - The need of adding a "custom value" input + * If the custom value is the only available value + * (defined by its data 'is_single_and_custom'), + * the custom value will have it's own input & label + * + * 'change' events triggered by the user entered custom values are ignored since they + * are not relevant + * + * @param {MouseEvent} ev + */ + onChangeVariant: function (ev) { + var $parent = $(ev.target).closest('.js_product'); + if (!$parent.data('uniqueId')) { + $parent.data('uniqueId', _.uniqueId()); + } + this._throttledGetCombinationInfo($parent.data('uniqueId'))(ev); + }, + /** + * @see onChangeVariant + * + * @private + * @param {Event} ev + * @returns {Deferred} + */ + _getCombinationInfo: function (ev) { + var self = this; + + if ($(ev.target).hasClass('variant_custom_value')) { + return Promise.resolve(); + } + + var $parent = $(ev.target).closest('.js_product'); + var qty = $parent.find('input[name="add_qty"]').val(); + var combination = this.getSelectedVariantValues($parent); + var parentCombination = $parent.find('ul[data-attribute_exclusions]').data('attribute_exclusions').parent_combination; + var productTemplateId = parseInt($parent.find('.product_template_id').val()); + + self._checkExclusions($parent, combination); + + return ajax.jsonRpc(this._getUri('/sale/get_combination_info'), 'call', { + 'product_template_id': productTemplateId, + 'product_id': this._getProductId($parent), + 'combination': combination, + 'add_qty': parseInt(qty), + 'pricelist_id': this.pricelistId || false, + 'parent_combination': parentCombination, + }).then(function (combinationData) { + self._onChangeCombination(ev, $parent, combinationData); + }); + }, + + /** + * Will add the "custom value" input for this attribute value if + * the attribute value is configured as "custom" (see product_attribute_value.is_custom) + * + * @private + * @param {MouseEvent} ev + */ + handleCustomValues: function ($target) { + var $variantContainer; + var $customInput = false; + if ($target.is('input[type=radio]') && $target.is(':checked')) { + $variantContainer = $target.closest('ul').closest('li'); + $customInput = $target; + } else if ($target.is('select')) { + $variantContainer = $target.closest('li'); + $customInput = $target + .find('option[value="' + $target.val() + '"]'); + } + + if ($variantContainer) { + if ($customInput && $customInput.data('is_custom') === 'True') { + var attributeValueId = $customInput.data('value_id'); + var attributeValueName = $customInput.data('value_name'); + + if ($variantContainer.find('.variant_custom_value').length === 0 + || $variantContainer + .find('.variant_custom_value') + .data('custom_product_template_attribute_value_id') !== parseInt(attributeValueId)) { + $variantContainer.find('.variant_custom_value').remove(); + + var $input = $('<input>', { + type: 'text', + 'data-custom_product_template_attribute_value_id': attributeValueId, + 'data-attribute_value_name': attributeValueName, + class: 'variant_custom_value form-control' + }); + + var isRadioInput = $target.is('input[type=radio]') && + $target.closest('label.css_attribute_color').length === 0; + + if (isRadioInput && $customInput.data('is_single_and_custom') !== 'True') { + $input.addClass('custom_value_radio'); + $target.closest('div').after($input); + } else { + $input.attr('placeholder', attributeValueName); + $input.addClass('custom_value_own_line'); + $variantContainer.append($input); + } + } + } else { + $variantContainer.find('.variant_custom_value').remove(); + } + } + }, + + /** + * Hack to add and remove from cart with json + * + * @param {MouseEvent} ev + */ + onClickAddCartJSON: function (ev) { + ev.preventDefault(); + var $link = $(ev.currentTarget); + var $input = $link.closest('.input-group').find("input"); + var min = parseFloat($input.data("min") || 0); + var max = parseFloat($input.data("max") || Infinity); + var previousQty = parseFloat($input.val() || 0, 10); + var quantity = ($link.has(".fa-minus").length ? -1 : 1) + previousQty; + var newQty = quantity > min ? (quantity < max ? quantity : max) : min; + + if (newQty !== previousQty) { + $input.val(newQty).trigger('change'); + } + return false; + }, + + /** + * When the quantity is changed, we need to query the new price of the product. + * Based on the price list, the price might change when quantity exceeds X + * + * @param {MouseEvent} ev + */ + onChangeAddQuantity: function (ev) { + var $parent; + + if ($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){ + $parent = $(ev.currentTarget).closest('.oe_optional_products_modal'); + } else if ($(ev.currentTarget).closest('form').length > 0){ + $parent = $(ev.currentTarget).closest('form'); + } else { + $parent = $(ev.currentTarget).closest('.o_product_configurator'); + } + + this.triggerVariantChange($parent); + }, + + /** + * Triggers the price computation and other variant specific changes + * + * @param {$.Element} $container + */ + triggerVariantChange: function ($container) { + var self = this; + $container.find('ul[data-attribute_exclusions]').trigger('change'); + $container.find('input.js_variant_change:checked, select.js_variant_change').each(function () { + self.handleCustomValues($(this)); + }); + }, + + /** + * Will look for user custom attribute values + * in the provided container + * + * @param {$.Element} $container + * @returns {Array} array of custom values with the following format + * {integer} custom_product_template_attribute_value_id + * {string} attribute_value_name + * {string} custom_value + */ + getCustomVariantValues: function ($container) { + var variantCustomValues = []; + $container.find('.variant_custom_value').each(function (){ + var $variantCustomValueInput = $(this); + if ($variantCustomValueInput.length !== 0){ + variantCustomValues.push({ + 'custom_product_template_attribute_value_id': $variantCustomValueInput.data('custom_product_template_attribute_value_id'), + 'attribute_value_name': $variantCustomValueInput.data('attribute_value_name'), + 'custom_value': $variantCustomValueInput.val(), + }); + } + }); + + return variantCustomValues; + }, + + /** + * Will look for attribute values that do not create product variant + * (see product_attribute.create_variant "dynamic") + * + * @param {$.Element} $container + * @returns {Array} array of attribute values with the following format + * {integer} custom_product_template_attribute_value_id + * {string} attribute_value_name + * {integer} value + * {string} attribute_name + * {boolean} is_custom + */ + getNoVariantAttributeValues: function ($container) { + var noVariantAttributeValues = []; + var variantsValuesSelectors = [ + 'input.no_variant.js_variant_change:checked', + 'select.no_variant.js_variant_change' + ]; + + $container.find(variantsValuesSelectors.join(',')).each(function (){ + var $variantValueInput = $(this); + var singleNoCustom = $variantValueInput.data('is_single') && !$variantValueInput.data('is_custom'); + + if ($variantValueInput.is('select')){ + $variantValueInput = $variantValueInput.find('option[value=' + $variantValueInput.val() + ']'); + } + + if ($variantValueInput.length !== 0 && !singleNoCustom){ + noVariantAttributeValues.push({ + 'custom_product_template_attribute_value_id': $variantValueInput.data('value_id'), + 'attribute_value_name': $variantValueInput.data('value_name'), + 'value': $variantValueInput.val(), + 'attribute_name': $variantValueInput.data('attribute_name'), + 'is_custom': $variantValueInput.data('is_custom') + }); + } + }); + + return noVariantAttributeValues; + }, + + /** + * Will return the list of selected product.template.attribute.value ids + * For the modal, the "main product"'s attribute values are stored in the + * "unchanged_value_ids" data + * + * @param {$.Element} $container the container to look into + */ + getSelectedVariantValues: function ($container) { + var values = []; + var unchangedValues = $container + .find('div.oe_unchanged_value_ids') + .data('unchanged_value_ids') || []; + + var variantsValuesSelectors = [ + 'input.js_variant_change:checked', + 'select.js_variant_change' + ]; + _.each($container.find(variantsValuesSelectors.join(', ')), function (el) { + values.push(+$(el).val()); + }); + + return values.concat(unchangedValues); + }, + + /** + * Will return a promise: + * + * - If the product already exists, immediately resolves it with the product_id + * - If the product does not exist yet ("dynamic" variant creation), this method will + * create the product first and then resolve the promise with the created product's id + * + * @param {$.Element} $container the container to look into + * @param {integer} productId the product id + * @param {integer} productTemplateId the corresponding product template id + * @param {boolean} useAjax wether the rpc call should be done using ajax.jsonRpc or using _rpc + * @returns {Promise} the promise that will be resolved with a {integer} productId + */ + selectOrCreateProduct: function ($container, productId, productTemplateId, useAjax) { + var self = this; + productId = parseInt(productId); + productTemplateId = parseInt(productTemplateId); + var productReady = Promise.resolve(); + if (productId) { + productReady = Promise.resolve(productId); + } else { + var params = { + product_template_id: productTemplateId, + product_template_attribute_value_ids: + JSON.stringify(self.getSelectedVariantValues($container)), + }; + + var route = '/sale/create_product_variant'; + if (useAjax) { + productReady = ajax.jsonRpc(route, 'call', params); + } else { + productReady = this._rpc({route: route, params: params}); + } + } + + return productReady; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Will disable attribute value's inputs based on combination exclusions + * and will disable the "add" button if the selected combination + * is not available + * + * This will check both the exclusions within the product itself and + * the exclusions coming from the parent product (meaning that this product + * is an option of the parent product) + * + * It will also check that the selected combination does not exactly + * match a manually archived product + * + * @private + * @param {$.Element} $parent the parent container to apply exclusions + * @param {Array} combination the selected combination of product attribute values + */ + _checkExclusions: function ($parent, combination) { + var self = this; + var combinationData = $parent + .find('ul[data-attribute_exclusions]') + .data('attribute_exclusions'); + + $parent + .find('option, input, label') + .removeClass('css_not_available') + .attr('title', function () { return $(this).data('value_name') || ''; }) + .data('excluded-by', ''); + + // exclusion rules: array of ptav + // for each of them, contains array with the other ptav they exclude + if (combinationData.exclusions) { + // browse all the currently selected attributes + _.each(combination, function (current_ptav) { + if (combinationData.exclusions.hasOwnProperty(current_ptav)) { + // for each exclusion of the current attribute: + _.each(combinationData.exclusions[current_ptav], function (excluded_ptav) { + // disable the excluded input (even when not already selected) + // to give a visual feedback before click + self._disableInput( + $parent, + excluded_ptav, + current_ptav, + combinationData.mapped_attribute_names + ); + }); + } + }); + } + + // parent exclusions (tell which attributes are excluded from parent) + _.each(combinationData.parent_exclusions, function (exclusions, excluded_by){ + // check that the selected combination is in the parent exclusions + _.each(exclusions, function (ptav) { + + // disable the excluded input (even when not already selected) + // to give a visual feedback before click + self._disableInput( + $parent, + ptav, + excluded_by, + combinationData.mapped_attribute_names, + combinationData.parent_product_name + ); + }); + }); + }, + /** + * Extracted to a method to be extendable by other modules + * + * @param {$.Element} $parent + */ + _getProductId: function ($parent) { + return parseInt($parent.find('.product_id').val()); + }, + /** + * Will disable the input/option that refers to the passed attributeValueId. + * This is used for showing the user that some combinations are not available. + * + * It will also display a message explaining why the input is not selectable. + * Based on the "excludedBy" and the "productName" params. + * e.g: Not available with Color: Black + * + * @private + * @param {$.Element} $parent + * @param {integer} attributeValueId + * @param {integer} excludedBy The attribute value that excludes this input + * @param {Object} attributeNames A dict containing all the names of the attribute values + * to show a human readable message explaining why the input is disabled. + * @param {string} [productName] The parent product. If provided, it will be appended before + * the name of the attribute value that excludes this input + * e.g: Not available with Customizable Desk (Color: Black) + */ + _disableInput: function ($parent, attributeValueId, excludedBy, attributeNames, productName) { + var $input = $parent + .find('option[value=' + attributeValueId + '], input[value=' + attributeValueId + ']'); + $input.addClass('css_not_available'); + $input.closest('label').addClass('css_not_available'); + + if (excludedBy && attributeNames) { + var $target = $input.is('option') ? $input : $input.closest('label').add($input); + var excludedByData = []; + if ($target.data('excluded-by')) { + excludedByData = JSON.parse($target.data('excluded-by')); + } + + var excludedByName = attributeNames[excludedBy]; + if (productName) { + excludedByName = productName + ' (' + excludedByName + ')'; + } + excludedByData.push(excludedByName); + + $target.attr('title', _.str.sprintf(_t('Not available with %s'), excludedByData.join(', '))); + $target.data('excluded-by', JSON.stringify(excludedByData)); + } + }, + /** + * @see onChangeVariant + * + * @private + * @param {MouseEvent} ev + * @param {$.Element} $parent + * @param {Array} combination + */ + _onChangeCombination: function (ev, $parent, combination) { + var self = this; + var $price = $parent.find(".oe_price:first .oe_currency_value"); + var $default_price = $parent.find(".oe_default_price:first .oe_currency_value"); + var $optional_price = $parent.find(".oe_optional:first .oe_currency_value"); + $price.text(self._priceToStr(combination.price)); + $default_price.text(self._priceToStr(combination.list_price)); + + var isCombinationPossible = true; + if (!_.isUndefined(combination.is_combination_possible)) { + isCombinationPossible = combination.is_combination_possible; + } + this._toggleDisable($parent, isCombinationPossible); + + if (combination.has_discounted_price) { + $default_price + .closest('.oe_website_sale') + .addClass("discount"); + $optional_price + .closest('.oe_optional') + .removeClass('d-none') + .css('text-decoration', 'line-through'); + $default_price.parent().removeClass('d-none'); + } else { + $default_price + .closest('.oe_website_sale') + .removeClass("discount"); + $optional_price.closest('.oe_optional').addClass('d-none'); + $default_price.parent().addClass('d-none'); + } + + var rootComponentSelectors = [ + 'tr.js_product', + '.oe_website_sale', + '.o_product_configurator' + ]; + + // update images only when changing product + // or when either ids are 'false', meaning dynamic products. + // Dynamic products don't have images BUT they may have invalid + // combinations that need to disable the image. + if (!combination.product_id || + !this.last_product_id || + combination.product_id !== this.last_product_id) { + this.last_product_id = combination.product_id; + self._updateProductImage( + $parent.closest(rootComponentSelectors.join(', ')), + combination.display_image, + combination.product_id, + combination.product_template_id, + combination.carousel, + isCombinationPossible + ); + } + + $parent + .find('.product_id') + .first() + .val(combination.product_id || 0) + .trigger('change'); + + $parent + .find('.product_display_name') + .first() + .text(combination.display_name); + + $parent + .find('.js_raw_price') + .first() + .text(combination.price) + .trigger('change'); + + this.handleCustomValues($(ev.target)); + }, + + /** + * returns the formatted price + * + * @private + * @param {float} price + */ + _priceToStr: function (price) { + var l10n = _t.database.parameters; + var precision = 2; + + if ($('.decimal_precision').length) { + precision = parseInt($('.decimal_precision').last().data('precision')); + } + var formatted = _.str.sprintf('%.' + precision + 'f', price).split('.'); + formatted[0] = utils.insert_thousand_seps(formatted[0]); + return formatted.join(l10n.decimal_point); + }, + /** + * Returns a throttled `_getCombinationInfo` with a leading and a trailing + * call, which is memoized per `uniqueId`, and for which previous results + * are dropped. + * + * The uniqueId is needed because on the configurator modal there might be + * multiple elements triggering the rpc at the same time, and we need each + * individual product rpc to be executed, but only once per individual + * product. + * + * The leading execution is to keep good reactivity on the first call, for + * a better user experience. The trailing is because ultimately only the + * information about the last selected combination is useful. All + * intermediary rpc can be ignored and are therefore best not done at all. + * + * The DropMisordered is to make sure slower rpc are ignored if the result + * of a newer rpc has already been received. + * + * @private + * @param {string} uniqueId + * @returns {function} + */ + _throttledGetCombinationInfo: _.memoize(function (uniqueId) { + var dropMisordered = new concurrency.DropMisordered(); + var _getCombinationInfo = _.throttle(this._getCombinationInfo.bind(this), 500); + return function (ev, params) { + return dropMisordered.add(_getCombinationInfo(ev, params)); + }; + }), + /** + * Toggles the disabled class depending on the $parent element + * and the possibility of the current combination. + * + * @private + * @param {$.Element} $parent + * @param {boolean} isCombinationPossible + */ + _toggleDisable: function ($parent, isCombinationPossible) { + $parent.toggleClass('css_not_available', !isCombinationPossible); + }, + /** + * Updates the product image. + * This will use the productId if available or will fallback to the productTemplateId. + * + * @private + * @param {$.Element} $productContainer + * @param {boolean} displayImage will hide the image if true. It will use the 'invisible' class + * instead of d-none to prevent layout change + * @param {integer} product_id + * @param {integer} productTemplateId + */ + _updateProductImage: function ($productContainer, displayImage, productId, productTemplateId) { + var model = productId ? 'product.product' : 'product.template'; + var modelId = productId || productTemplateId; + var imageUrl = '/web/image/{0}/{1}/' + (this._productImageField ? this._productImageField : 'image_1024'); + var imageSrc = imageUrl + .replace("{0}", model) + .replace("{1}", modelId); + + var imagesSelectors = [ + 'span[data-oe-model^="product."][data-oe-type="image"] img:first', + 'img.product_detail_img', + 'span.variant_image img', + 'img.variant_image', + ]; + + var $img = $productContainer.find(imagesSelectors.join(', ')); + + if (displayImage) { + $img.removeClass('invisible').attr('src', imageSrc); + } else { + $img.addClass('invisible'); + } + }, + + /** + * Highlight selected color + * + * @private + * @param {MouseEvent} ev + */ + _onChangeColorAttribute: function (ev) { + var $parent = $(ev.target).closest('.js_product'); + $parent.find('.css_attribute_color') + .removeClass("active") + .filter(':has(input:checked)') + .addClass("active"); + }, + + /** + * Extension point for website_sale + * + * @private + * @param {string} uri The uri to adapt + */ + _getUri: function (uri) { + return uri; + } +}; + +return VariantMixin; + +}); |
