diff --git a/addons/test_website_modules/tests/test_performance.py b/addons/test_website_modules/tests/test_performance.py index 8345e69d2ae48..455ef169f6948 100644 --- a/addons/test_website_modules/tests/test_performance.py +++ b/addons/test_website_modules/tests/test_performance.py @@ -113,11 +113,7 @@ def setUpClass(cls): }) cls.product_images = cls.env['product.image'].with_context(default_product_tmpl_id=cls.productC.product_tmpl_id.id).create([{ 'name': 'Template image', - 'image_1920': blue_image, - }, { - 'name': 'Variant image', 'image_1920': red_image, - 'product_variant_id': cls.productC.id, }]) for i in range(20): @@ -148,7 +144,7 @@ def setUpClass(cls): images.append({ 'name': 'Variant image', 'image_1920': red_image, - 'product_variant_id': variant.id, + 'product_variant_ids': [Command.link(variant.id)], }) cls.env['product.image'].create(images) @@ -291,11 +287,11 @@ def _allow_to_use_cache(request): def _get_queries_shop(self): html = self.url_open('/shop').text - self.assertIn(f' Office Chair - Context View 10 - + Office Chair - Context View 10 - + Drawer Black - Context View 10 - + Office Lamp - Context View 10 - + Customizable Desk Steel - Context View 10 - + @@ -1034,91 +1034,91 @@ Four Person Desk - Context View 10 - + Desk Organizer - Context View 10 - + Individual Workplace - Context View 10 - + Office Chair Black - Context View 10 - + Drawer - Context View 10 - + Large Meeting Table - Context View 10 - + Cabinet Width Doors - Context View 10 - + Storage Box - Context View 10 - + Pedal Bin - Context View 10 - + Large Cabinet - Context View 10 - + Corner Desk Right Sit - Context View 10 - + Two-Seat Sofa - Context View 10 - + Desk Combination - Context View 10 - + @@ -1126,7 +1126,7 @@ Customizable Desk Steel - Detail View 11 - + diff --git a/addons/website_sale/models/product_image.py b/addons/website_sale/models/product_image.py index 8ded116f23e2e..ad1353ad81e65 100644 --- a/addons/website_sale/models/product_image.py +++ b/addons/website_sale/models/product_image.py @@ -2,7 +2,9 @@ import base64 -from odoo import _, api, fields, models +from collections import defaultdict + +from odoo import _, api, fields, models, Command from odoo.exceptions import ValidationError from odoo.tools.image import is_image_size_above @@ -23,8 +25,12 @@ class ProductImage(models.Model): product_tmpl_id = fields.Many2one( string="Product Template", comodel_name='product.template', ondelete='cascade', index=True, ) - product_variant_id = fields.Many2one( - string="Product Variant", comodel_name='product.product', ondelete='cascade', index=True, + product_variant_ids = fields.Many2many( + 'product.product', + string="Product Variants", + relation='product_image_product_variant_rel', + column1='product_image_id', + column2='product_variant_id', ) video_url = fields.Char( string="Video URL", @@ -37,6 +43,8 @@ class ProductImage(models.Model): compute='_compute_can_image_1024_be_zoomed', store=True, ) + attribute_value_ids = fields.Many2many('product.template.attribute.value') + is_template_image = fields.Boolean(compute='_compute_is_template_image', store=True) #=== COMPUTE METHODS ===# @@ -50,6 +58,11 @@ def _compute_embed_code(self): for image in self: image.embed_code = image.video_url and get_video_embed_code(image.video_url) or False + @api.depends('product_tmpl_id', 'product_variant_ids') + def _compute_is_template_image(self): + for image in self: + image.is_template_image = bool(image.product_tmpl_id and not image.product_variant_ids) + #=== ONCHANGE METHODS ===# @api.onchange('video_url') @@ -72,18 +85,103 @@ def _check_valid_video_url(self): def create(self, vals_list): """ We don't want the default_product_tmpl_id from the context - to be applied if we have a product_variant_id set to avoid + to be applied if we have a product_variant_ids set to avoid having the variant images to show also as template images. - But we want it if we don't have a product_variant_id set. + But we want it if we don't have a product_variant_ids set. """ context_without_template = self.with_context({k: v for k, v in self.env.context.items() if k != 'default_product_tmpl_id'}) normal_vals = [] variant_vals_list = [] for vals in vals_list: - if vals.get('product_variant_id') and 'default_product_tmpl_id' in self.env.context: + if vals.get('product_variant_ids') and 'default_product_tmpl_id' in self.env.context: + if not vals.get('attribute_value_ids'): + variant = self.env['product.product'].browse(vals['product_variant_ids'][0][1]) + vals['attribute_value_ids'] = [ + Command.set(variant.product_template_attribute_value_ids.ids) + ] variant_vals_list.append(vals) else: normal_vals.append(vals) - return super().create(normal_vals) + super(ProductImage, context_without_template).create(variant_vals_list) + images = super().create(normal_vals) + super(ProductImage, context_without_template).create(variant_vals_list) + images.filtered_domain([('attribute_value_ids', '!=', False)])._sync_variant_images() + return images + + def write(self, vals): + res = super().write(vals) + if 'attribute_value_ids' in vals: + self._sync_variant_images() + return res + + if 'sequence' in vals or 'image_1920' in vals: + self.mapped('product_variant_ids')._set_main_image_from_extra_images() + return res + + def unlink(self): + variants = self.product_variant_ids + res = super().unlink() + variants._set_main_image_from_extra_images() + return res + + # === BUSINESS METHODS === # + + def _sync_variant_images(self): + """Update the product variants to which each image applies. + + For each image, this method computes the set of product variants that match the image's + attribute values and updates the image's linked variants accordingly. Images without + attribute value are not applied to any variant. + + :return: None + :rtype: None + """ + impacted_variants = self.env['product.product'] + for image in self: + product_template = ( + image.product_variant_ids[:1].product_tmpl_id or image.product_tmpl_id + ) + old_variants = image.product_variant_ids + + if not product_template or not image.attribute_value_ids: + impacted_variants |= image.product_variant_ids + new_variants = self.env['product.product'] + image.product_variant_ids = [Command.clear()] + continue + + new_variants = product_template.product_variant_ids.filtered( + image._is_applicable_to_variant + ) + + image.product_variant_ids = [Command.set(new_variants.ids)] + + impacted_variants |= (old_variants | new_variants) + + impacted_variants._set_main_image_from_extra_images() + + def _is_applicable_to_variant(self, variant): + """Check whether this image applies to the given product variant. + + The image applies if the variant matches all attribute values set on the image. + Attributes that are not set do not affect the result. + + :param variant: product.product recordset + :return: Whether the image applies to the variant or not. + :rtype: bool + """ + self.ensure_one() + variant.ensure_one() + + variant_vals = { + ptav.attribute_id.id: ptav.id + for ptav in variant.product_template_attribute_value_ids + } + + image_vals_by_attr = defaultdict(set) + for val in self.attribute_value_ids: + image_vals_by_attr[val.attribute_id.id].add(val.id) + + return all( + variant_vals.get(attr_id) in allowed_vals + for attr_id, allowed_vals in image_vals_by_attr.items() + ) diff --git a/addons/website_sale/models/product_product.py b/addons/website_sale/models/product_product.py index a351326f7ba54..bc22b5794661b 100644 --- a/addons/website_sale/models/product_product.py +++ b/addons/website_sale/models/product_product.py @@ -14,10 +14,12 @@ class ProductProduct(models.Model): variant_ribbon_id = fields.Many2one(string="Variant Ribbon", comodel_name='product.ribbon') website_id = fields.Many2one(related='product_tmpl_id.website_id', readonly=False) - product_variant_image_ids = fields.One2many( + product_variant_image_ids = fields.Many2many( + 'product.image', string="Extra Variant Images", - comodel_name='product.image', - inverse_name='product_variant_id', + relation='product_image_product_variant_rel', + column1='product_variant_id', + column2='product_image_id', ) base_unit_count = fields.Float( @@ -112,16 +114,19 @@ def _get_images(self): """Return a list of records implementing `image.mixin` to display on the carousel on the website for this variant. - This returns a list and not a recordset because the records might be - from different models (template, variant and image). + Variant and template extra images are combined into a single list + and ordered by their sequence, so the end user experiences a single + unified carousel regardless of image source. - It contains in this order: the main image of the variant (which will fall back on the main - image of the template, if unset), the Variant Extra Images, and the Template Extra Images. + If no extra images exist, the main image of the variant is returned + (which will fall back on the main image of the template, if unset). """ self.ensure_one() - variant_images = list(self.product_variant_image_ids) - template_images = list(self.product_tmpl_id.product_template_image_ids) - return [self] + variant_images + template_images + variant_images = self.product_variant_image_ids + template_images = self.product_tmpl_id.product_template_image_ids.filtered('is_template_image') + images = (variant_images | template_images).sorted('sequence') + + return list(images) or [self] def _get_combination_info_variant(self, **kwargs): """Return the variant info based on its combination. @@ -223,7 +228,8 @@ def _get_extra_image_1920_urls(self): self.ensure_one() return [ self.env['website'].image_url(extra_image, 'image_1920') - for extra_image in self.product_variant_image_ids + self.product_template_image_ids + for extra_image in self.product_variant_image_ids + + self.product_template_image_ids.filtered('is_template_image') if extra_image.image_128 # only images, no video urls ] @@ -235,6 +241,15 @@ def write(self, vals): ('product_id', 'in', self.ids), ('order_id', 'any', [('website_id', '!=', False)]), ]).unlink() + + if 'product_variant_image_ids' in vals: + removed_image_ids = [ + cmd[1] for cmd in vals['product_variant_image_ids'] + if cmd[0] == 3 + ] + if removed_image_ids: + self.env['product.image'].browse(removed_image_ids).unlink() + return super().write(vals) def _is_in_wishlist(self): @@ -280,3 +295,11 @@ def _get_image_1024_url(self): """ self.ensure_one() return self.env['website'].image_url(self, 'image_1024') + + def _set_main_image_from_extra_images(self): + for product in self: + first_extra_image = product.product_variant_image_ids.sorted("sequence")[:1] + + product.image_variant_1920 = ( + first_extra_image.image_1920 if first_extra_image else False + ) diff --git a/addons/website_sale/models/product_template.py b/addons/website_sale/models/product_template.py index ed55b5ee964dd..f52eda98eb37a 100644 --- a/addons/website_sale/models/product_template.py +++ b/addons/website_sale/models/product_template.py @@ -270,6 +270,32 @@ def write(self, vals): #=== BUSINESS METHODS ===# + def get_attribute_values_for_image_assignment(self, product_variant_id=False): + current_value_ids = ( + self.env['product.product'] + .browse(product_variant_id) + .product_template_attribute_value_ids.ids + if product_variant_id else [] + ) + + attributes = [ + { + 'id': line.attribute_id.id, + 'values': [ + { + 'id': ptav.id, + 'name': ptav.name, + } + for ptav in line.product_template_value_ids + if ptav.ptav_active + ], + } + for line in self.attribute_line_ids + if line.attribute_id.create_variant != 'no_variant' + ] + + return {'attributes': attributes, 'current_value_ids': current_value_ids} + def _prepare_variant_values(self, combination): variant_dict = super()._prepare_variant_values(combination) variant_dict['base_unit_count'] = self.base_unit_count @@ -849,7 +875,7 @@ def _get_images(self): Template Extra Images. """ self.ensure_one() - return [self] + list(self.product_template_image_ids) + return [self] + list(self.product_template_image_ids.filtered('is_template_image')) def _get_attribute_value_domain(self, attribute_value_dict): return [ diff --git a/addons/website_sale/static/src/js/product_image/product_image.js b/addons/website_sale/static/src/js/product_image/product_image.js new file mode 100644 index 0000000000000..04506414a87a0 --- /dev/null +++ b/addons/website_sale/static/src/js/product_image/product_image.js @@ -0,0 +1,89 @@ +import { Component, useState } from '@odoo/owl'; +import { Dropdown } from '@web/core/dropdown/dropdown'; +import { useDropdownState } from '@web/core/dropdown/dropdown_hooks'; +import { DropdownItem } from '@web/core/dropdown/dropdown_item'; +import { x2ManyCommands } from "@web/core/orm_service"; +import { registry } from '@web/core/registry'; +import { useService } from '@web/core/utils/hooks'; + +export class ProductImage extends Component { + static template = 'variant_image_assignment'; + static components = { Dropdown, DropdownItem }; + static props = { + id: String, + name: String, + readonly: Boolean, + record: Object, + }; + + setup() { + this.orm = useService('orm'); + this.record = this.props.record; + + this.state = useState({ + attributes: [], + checkedIds: new Set(), + }); + + this.dropdownState = useDropdownState(); + } + + get showDropdown() { + const parent = this.record._parentRecord; + if (!parent.resId) { + return false; + } + if (this.record.data.product_tmpl_id) { + return parent.data.product_variant_count > 1 + } + return true; + } + + get selectedCount() { + return this.record.data[this.props.name].count || 0; + } + + async beforeOpen() { + const isNewRecord = !this.record.resId; + const productTmplId = this.record.data.product_tmpl_id.id || this.record.context.active_id; + const productVariantId = isNewRecord + ? this.record.context.default_product_variant_ids?.[0] + : false; + + const { attributes, current_value_ids } = await this.orm.call( + 'product.template', + 'get_attribute_values_for_image_assignment', + [productTmplId, productVariantId], + ); + + this.state.attributes = attributes; + + const ids = current_value_ids.length + ? current_value_ids + : this.record.data[this.props.name]._currentIds; + + if (current_value_ids.length) { + this.record.update({ [this.props.name]: [x2ManyCommands.set(current_value_ids)] }); + } + this.state.checkedIds = new Set(ids); + } + + toggleValue(valueId) { + const checkedIds = this.state.checkedIds; + const isChecked = checkedIds.has(valueId); + + isChecked ? checkedIds.delete(valueId) : checkedIds.add(valueId); + + this.record.update({ + [this.props.name]: [ + isChecked + ? x2ManyCommands.unlink(valueId) + : x2ManyCommands.link(valueId), + ], + }); + } +} + +const productImage = { component: ProductImage }; + +registry.category('fields').add('variant_image_assignment', productImage); diff --git a/addons/website_sale/static/src/js/product_image/product_image.xml b/addons/website_sale/static/src/js/product_image/product_image.xml new file mode 100644 index 0000000000000..d43bc6e4fe00d --- /dev/null +++ b/addons/website_sale/static/src/js/product_image/product_image.xml @@ -0,0 +1,61 @@ + + + + + + + + + + Assign to variant + + + + + + + + + + + + + + + + + + + + + + And + + + + + + + + + diff --git a/addons/website_sale/static/src/scss/kanban_record.scss b/addons/website_sale/static/src/scss/kanban_record.scss index 355421f45f849..9454bd05b7503 100644 --- a/addons/website_sale/static/src/scss/kanban_record.scss +++ b/addons/website_sale/static/src/scss/kanban_record.scss @@ -1,17 +1,9 @@ -.o_form_renderer { - .o_field_x2_many_media_viewer .o_kanban_renderer { - --KanbanRecord-width: 100px; +.o_form_renderer .o_field_x2_many_media_viewer .o_website_sale_image_list_card_image { + width: 12em; - article.o_kanban_record { - display: flex; - justify-content: center; - margin-bottom: unset !important; - - & img { - height: 128px; - width: 168.86px; - object-fit: contain; - } - } + img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: contain; } } diff --git a/addons/website_sale/static/src/scss/product_tile.scss b/addons/website_sale/static/src/scss/product_tile.scss index 7d3b713990f29..9cebbbc02608a 100644 --- a/addons/website_sale/static/src/scss/product_tile.scss +++ b/addons/website_sale/static/src/scss/product_tile.scss @@ -109,6 +109,7 @@ .oe_product_image_img_wrapper { &.oe_product_image_img_wrapper_secondary { display: var(--o-wsale-card-img-wrapper-secondary-display, none); + pointer-events: none; } } .oe_product_image_img, .oe_product_image_img_secondary { diff --git a/addons/website_sale/static/src/scss/website_sale_backend.scss b/addons/website_sale/static/src/scss/website_sale_backend.scss index f020e2112150f..2378ae4dfbdbb 100644 --- a/addons/website_sale/static/src/scss/website_sale_backend.scss +++ b/addons/website_sale/static/src/scss/website_sale_backend.scss @@ -16,20 +16,6 @@ @include media-breakpoint-up(xl) { flex: 0 0 percentage(1/6); } - // make the image square and in the center - .o_squared_image { - position: relative; - overflow: hidden; - padding-bottom: 100%; - > img { - position: absolute; - margin: auto; - top: 0; - left: 0; - bottom: 0; - right: 0; - } - } .o_product_image_size { position: absolute; diff --git a/addons/website_sale/static/src/website_builder/product_image_option_plugin.js b/addons/website_sale/static/src/website_builder/product_image_option_plugin.js index 6cfee4ae1a130..804db4a91413f 100644 --- a/addons/website_sale/static/src/website_builder/product_image_option_plugin.js +++ b/addons/website_sale/static/src/website_builder/product_image_option_plugin.js @@ -56,6 +56,7 @@ export class SetPositionAction extends BuilderAction { image_res_model: el.parentElement.dataset.oeModel, image_res_id: el.parentElement.dataset.oeId, move: value, + product_variant_id: this.document.querySelector('[data-product-variant-id]').dataset.productVariantId, }; await rpc("/shop/product/resequence-image", params); diff --git a/addons/website_sale/static/src/website_builder/product_page_option.xml b/addons/website_sale/static/src/website_builder/product_page_option.xml index 56de75bfd5ee8..8efd559655e94 100644 --- a/addons/website_sale/static/src/website_builder/product_page_option.xml +++ b/addons/website_sale/static/src/website_builder/product_page_option.xml @@ -112,11 +112,6 @@ - - Replace - Add More Remove All diff --git a/addons/website_sale/static/src/website_builder/product_page_option_plugin.js b/addons/website_sale/static/src/website_builder/product_page_option_plugin.js index dab98fbabb7bb..0a3c3fc110599 100644 --- a/addons/website_sale/static/src/website_builder/product_page_option_plugin.js +++ b/addons/website_sale/static/src/website_builder/product_page_option_plugin.js @@ -24,7 +24,6 @@ class ProductPageOptionPlugin extends Plugin { ProductPageImageRoundnessAction, ProductPageImageGridSpacingAction, ProductPageImageGridColumnsAction, - ProductReplaceMainImageAction, ProductAddExtraImageAction, ProductRemoveAllExtraImagesAction, }, @@ -186,9 +185,9 @@ export class BaseProductPageAction extends BuilderAction { this.reload = {}; const mainEl = this.document.querySelector(ProductPageOption.selector); if (mainEl) { - const productProduct = mainEl.querySelector('[data-oe-model="product.product"]'); + const productProduct = mainEl.querySelector('[data-product-variant-id]'); const productTemplate = mainEl.querySelector('[data-oe-model="product.template"]'); - this.productProductID = productProduct ? productProduct.dataset.oeId : null; + this.productProductID = productProduct ? productProduct.dataset.productVariantId : null; this.productTemplateID = productTemplate ? productTemplate.dataset.oeId : null; this.model = "product.template"; if (this.productProductID) { @@ -315,48 +314,6 @@ export class ProductPageImageGridColumnsAction extends BaseProductPageAction { }); } } -export class ProductReplaceMainImageAction extends BaseProductPageAction { - static id = "productReplaceMainImage"; - static dependencies = [...super.dependencies, "media"]; - setup() { - super.setup(); - this.reload = false; - this.canTimeout = false; - } - apply({ editingElement: productDetailMainEl }) { - // Emulate click on the main image of the carousel. - const image = productDetailMainEl.querySelector( - `[data-oe-model="${this.model}"][data-oe-field=image_1920] img` - ); - this.dependencies.media.openMediaDialog({ - multiImages: false, - visibleTabs: ["IMAGES"], - node: productDetailMainEl, - save: (imgEl, selectedMedia) => { - const attachment = selectedMedia[0]; - if (["image/gif", "image/svg+xml"].includes(attachment.mimetype)) { - image.src = attachment.image_src; - return; - } - const originalSize = Math.max(imgEl.width, imgEl.height); - const ratio = Math.min(originalSize, 1920) / originalSize; - const canvas = document.createElement("canvas"); - canvas.width = parseInt(imgEl.width * ratio); - canvas.height = parseInt(imgEl.height * ratio); - const ctx = canvas.getContext("2d") - ctx.fillStyle = "transparent"; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(imgEl, 0, 0); - image.src = canvas.toDataURL("image/webp"); - const { model, productProductID: productID, productTemplateID: templateID } = this; - const resID = parseInt(model === "product.product" ? productID : templateID); - this.services.orm.write(model, [resID], { - image_1920: image.src.split(",")[1], - }); - }, - }); - } -} export class ProductAddExtraImageAction extends BaseProductPageAction { static id = "productAddExtraImage"; diff --git a/addons/website_sale/static/tests/builder/product_page_option.test.js b/addons/website_sale/static/tests/builder/product_page_option.test.js deleted file mode 100644 index 9fabb604d5adc..0000000000000 --- a/addons/website_sale/static/tests/builder/product_page_option.test.js +++ /dev/null @@ -1,145 +0,0 @@ -import { expect, test } from "@odoo/hoot"; -import { waitForNone } from "@odoo/hoot-dom"; -import { - contains, - dataURItoBlob, - defineModels, - fields, - models, - onRpc, -} from "@web/../tests/web_test_helpers"; -import { - defineWebsiteModels, - setupWebsiteBuilder, -} from "@website/../tests/builder/website_helpers"; - -class ProductProduct extends models.Model { - _name = "product.product"; - - id = fields.Integer(); - name = fields.Char(); - image_1920 = fields.Image(); - - _records = [ - { id: 13, name: "Variant 1", image_1920: "/9j/4AAQSkL6D8wwP//Z" }, - { id: 14, name: "Variant 2", image_1920: null }, - ]; -} - -class ProductRibbon extends models.Model { - _name = "product.ribbon"; -} - -defineWebsiteModels(); -defineModels([ProductProduct, ProductRibbon]); - -test("Product page options", async () => { - const { waitSidebarUpdated } = await setupWebsiteBuilder(` - - - - - - - - - - - - - - - - - - - - - - `); - - onRpc("/website/theme_customize_data", () => expect.step("theme_customize_data")); - onRpc("/website/theme_customize_data_get", () => expect.step("theme_customize_data_get")); - onRpc("/shop/config/website", () => expect.step("config")); - onRpc("ir.ui.view", "save", () => { - expect.step("save"); - return []; - }); - onRpc("product.product", "write", () => { - expect.step("product_write"); - return true; - }); - - const base64Image = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5" + - "AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYIIA"; - onRpc("ir.attachment", "search_read", () => [ - { - mimetype: "image/png", - image_src: "/web/image/hoot.png", - access_token: false, - public: true, - }, - ]); - onRpc("/html_editor/get_image_info", () => { - expect.step("get_image_info"); - return { - attachment: { id: 1 }, - original: { id: 1, image_src: "/web/image/hoot.png", mimetype: "image/png" }, - }; - }); - onRpc("/web/image/hoot.png", () => { - // converted image won't be used if original is not larger - return dataURItoBlob(base64Image + "A".repeat(1000)); - }); - - await contains(":iframe .o_wsale_product_page").click(); - await contains("[data-action-id=productReplaceMainImage]").click(); - await contains(".o_select_media_dialog .o_existing_attachment_cell button").click(); - await expect.waitForSteps(["theme_customize_data_get", "get_image_info", "product_write"]); - await waitForNone(".o_select_media_dialog"); - - expect(":iframe #product_detail_main img[src^='data:image/webp;base64,']").toHaveCount(1); - expect(":iframe img").toHaveCount(2); - await contains("button#o_wsale_image_width").click(); - // Avoid selecting the first option to prevent the image layout option from disappearing - await contains("[data-action-id=productPageImageWidth][data-action-value='50_pc']").click(); - await expect.waitForSteps(["config"]); - await waitSidebarUpdated(); - - await contains("button#o_wsale_image_layout").click(); - await contains("[data-action-id=productPageImageLayout]").click(); - await waitSidebarUpdated(); - await expect.waitForSteps([ - // Activate the carousel view and change the shop config - "config", - // Save the pending image width class changes - "save", - // Save the image changes - "save", - // Reload the view - "theme_customize_data_get", - ]); - - // Make sure that clicking quickly on a builder button after an clicking on - // an action that reloads the editor does not produce a crash. - await contains("[data-action-id=websiteConfig].o_we_buy_now_btn").click(); - await contains("button#o_wsale_image_layout").click(); - await expect.waitForSteps(["theme_customize_data", "theme_customize_data_get"]); -}); diff --git a/addons/website_sale/templates/product_page_templates.xml b/addons/website_sale/templates/product_page_templates.xml index ca17dfd75bb89..1473ee8bee9d2 100644 --- a/addons/website_sale/templates/product_page_templates.xml +++ b/addons/website_sale/templates/product_page_templates.xml @@ -100,6 +100,7 @@ diff --git a/addons/website_sale/templates/product_tile_templates.xml b/addons/website_sale/templates/product_tile_templates.xml index 9733554b0442e..bdc3534071829 100644 --- a/addons/website_sale/templates/product_tile_templates.xml +++ b/addons/website_sale/templates/product_tile_templates.xml @@ -46,23 +46,14 @@ > - - + + + + - - - - - - + + + + @@ -67,6 +73,7 @@ + diff --git a/addons/website_sale/views/product_views.xml b/addons/website_sale/views/product_views.xml index b890ced71e903..370cf2d2ac029 100644 --- a/addons/website_sale/views/product_views.xml +++ b/addons/website_sale/views/product_views.xml @@ -151,6 +151,9 @@ + + 1 + @@ -211,8 +214,6 @@ - - - - - + + + @@ -260,10 +261,23 @@ - - - + + + + + 1 +