diff --git a/website_sale_variant_extra_fields/README.rst b/website_sale_variant_extra_fields/README.rst new file mode 100644 index 0000000000..99773a7d87 --- /dev/null +++ b/website_sale_variant_extra_fields/README.rst @@ -0,0 +1,119 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================================ +Website Sale Variant Extra Field +================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:9cc0cda167991a1639bba1b8c4b5918d46a6145dfe00601164f4bd2fc8ab7c30 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fe--commerce-lightgray.png?logo=github + :target: https://github.com/OCA/e-commerce/tree/19.0/website_sale_variant_extra_fields + :alt: OCA/e-commerce +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/e-commerce-19-0/e-commerce-19-0-website_sale_variant_extra_fields + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/e-commerce&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +The standard *Product Page Extra Fields* configuration lets a website +manager pick product fields to display on the shop product page. It only +supports char and binary fields on the product template, and the +displayed value is read once when the page loads, so it never updates +when the customer switches variants. + +This module removes both limitations: + +- product variant fields can be selected, in addition to product + template fields; +- more field types are supported: numbers, dates, datetimes, dropdown + (selection) values and linked records, in addition to char and binary + fields; +- when a variant field is selected, its value refreshes live on the + product page as soon as the customer picks a different combination of + attributes. + + - decimal numbers are displayed with the number of decimals configured + for the selected field. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Go to *Website > Configuration > Settings* and open the *Product Page + Extra Fields* configuration of the website. +2. Add a line and pick any field of a product or of a product variant. + Text, file, number, date, date and time, dropdown, and linked record + fields are available. +3. Go to the shop and open a product page. The label and the value of + each selected field are displayed below the product details (if the + field has a value on the displayed product or variant). +4. Select another variant attribute on that page. The values coming from + a product.variant field are updated immediately, while the values + coming from a product.template field stay unchanged. + +A variant field with no value on the displayed variant is simply hidden, +and appears again as soon as the customer selects a variant that has a +value. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- `Camptocamp `__: + + - Ricardoalso + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/e-commerce `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_sale_variant_extra_fields/__init__.py b/website_sale_variant_extra_fields/__init__.py new file mode 100644 index 0000000000..2bc1433e72 --- /dev/null +++ b/website_sale_variant_extra_fields/__init__.py @@ -0,0 +1,3 @@ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from . import models diff --git a/website_sale_variant_extra_fields/__manifest__.py b/website_sale_variant_extra_fields/__manifest__.py new file mode 100644 index 0000000000..98cda9908f --- /dev/null +++ b/website_sale_variant_extra_fields/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +{ + "name": "Website Sale Variant Extra Field", + "summary": "Show variant fields and more field types in the product page " + "extra fields", + "version": "19.0.1.0.0", + "development_status": "Beta", + "category": "Website", + "website": "https://github.com/OCA/e-commerce", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "LGPL-3", + "application": False, + "installable": True, + "depends": ["website_sale"], + "data": ["views/templates.xml"], + "assets": { + "web.assets_frontend": [ + "/website_sale_variant_extra_fields/static/src/js/*.esm.js", + ], + }, +} diff --git a/website_sale_variant_extra_fields/models/__init__.py b/website_sale_variant_extra_fields/models/__init__.py new file mode 100644 index 0000000000..675c1a36a8 --- /dev/null +++ b/website_sale_variant_extra_fields/models/__init__.py @@ -0,0 +1,2 @@ +from . import website_sale_extra_field +from . import product_template diff --git a/website_sale_variant_extra_fields/models/product_template.py b/website_sale_variant_extra_fields/models/product_template.py new file mode 100644 index 0000000000..84c9684b19 --- /dev/null +++ b/website_sale_variant_extra_fields/models/product_template.py @@ -0,0 +1,33 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + def _get_combination_info( + self, + combination=False, + product_id=False, + add_qty=1.0, + uom_id=False, + only_template=False, + ): + combination_info = super()._get_combination_info( + combination=combination, + product_id=product_id, + add_qty=add_qty, + uom_id=uom_id, + only_template=only_template, + ) + website = self.env["website"].get_current_website() + extra_fields = website.shop_extra_field_ids.filtered("is_variant_field") + if not extra_fields: + return combination_info + variant = self.env["product.product"].browse(combination_info.get("product_id")) + combination_info["variant_extra_fields"] = extra_fields._get_rendered_values( + variant + ) + return combination_info diff --git a/website_sale_variant_extra_fields/models/website_sale_extra_field.py b/website_sale_variant_extra_fields/models/website_sale_extra_field.py new file mode 100644 index 0000000000..e8d69cb55d --- /dev/null +++ b/website_sale_variant_extra_fields/models/website_sale_extra_field.py @@ -0,0 +1,123 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from markupsafe import Markup + +from odoo import api, fields, models + +VARIANT_MODEL = "product.product" + +EXTRA_FIELD_MODELS = ["product.template", "product.product"] + +EXTRA_FIELD_TTYPES = [ + "char", + "binary", + "integer", + "float", + "date", + "datetime", + "selection", + "many2one", + "one2many", + "many2many", +] + + +class WebsiteSaleExtraField(models.Model): + _inherit = "website.sale.extra.field" + + field_id = fields.Many2one( + domain=[ + ("model_id.model", "in", EXTRA_FIELD_MODELS), + ("ttype", "in", EXTRA_FIELD_TTYPES), + ], + ) + is_variant_field = fields.Boolean(compute="_compute_is_variant_field") + + @api.depends("field_id.model_id.model") + def _compute_is_variant_field(self): + for extra_field in self: + extra_field.is_variant_field = ( + extra_field.field_id.sudo().model_id.model == VARIANT_MODEL + ) + + def _get_source_record(self, product, product_variant): + """Return the record the extra field value must be read from. + + Variant scoped fields are read from the currently displayed variant, + template scoped ones from the product template. + """ + self.ensure_one() + return product_variant if self.is_variant_field else product + + def _get_render_options(self): + """Return the ``t-options`` used to render the extra field value.""" + self.ensure_one() + field_id = self.field_id.sudo() + ttype = field_id.ttype + options = {"widget": ttype} + field = self.env[field_id.model_id.model]._fields.get(self.name) + if not field: + return options + if ttype == "float": + # Without an explicit precision, `ir.qweb.field.float` falls back to + # a significant digits heuristic instead of the decimal precision + # configured on the field. + digits = field.get_digits(self.env) + if digits: + options["precision"] = digits[1] + elif ttype == "selection": + # `ir.qweb.field.selection` requires the labels mapping, which is + # only injected automatically when rendering a record, not a value. + options["selection"] = dict(field.get_description(self.env)["selection"]) + return options + + def _get_converter(self): + """Return the ``ir.qweb.field`` model rendering the extra field value. + + Some field types have no dedicated converter, QWeb falls back on the + base one for them. + """ + self.ensure_one() + model = f"ir.qweb.field.{self.field_id.sudo().ttype}" + return self.env[model] if model in self.env else self.env["ir.qweb.field"] + + def _render_value(self, record): + """Return the HTML rendering of the extra field value on ``record``.""" + self.ensure_one() + if not record: + return "" + value = record.sudo()[self.name] + if not value: + return "" + field_id = self.field_id.sudo() + if field_id.ttype == "binary": + return Markup( + '' + '' + ) % (field_id.model_id.model, record.id, self.name) + rendered = self._get_converter().value_to_html( + value, self._get_render_options() + ) + return Markup(rendered) if rendered else "" + + def _get_rendered_values(self, record): + """Return ``{field name: rendered value}`` for every field of ``self``.""" + return { + extra_field.name: extra_field._render_value(record) for extra_field in self + } + + def _has_content_to_display(self, product, product_variant): + """Whether the extra fields block must be rendered for that product. + + Variant scoped fields always reserve their place in the page: their value + is refreshed client side when the customer selects another variant, so an + empty value on the variant displayed first must not remove the block. + """ + return any( + extra_field.is_variant_field + or extra_field._render_value( + extra_field._get_source_record(product, product_variant) + ) + for extra_field in self + ) diff --git a/website_sale_variant_extra_fields/pyproject.toml b/website_sale_variant_extra_fields/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/website_sale_variant_extra_fields/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_sale_variant_extra_fields/readme/CONTRIBUTORS.md b/website_sale_variant_extra_fields/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..f27dfd8053 --- /dev/null +++ b/website_sale_variant_extra_fields/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Camptocamp](https://www.camptocamp.com/): + - Ricardoalso \<\> diff --git a/website_sale_variant_extra_fields/readme/DESCRIPTION.md b/website_sale_variant_extra_fields/readme/DESCRIPTION.md new file mode 100644 index 0000000000..7fe46a9a06 --- /dev/null +++ b/website_sale_variant_extra_fields/readme/DESCRIPTION.md @@ -0,0 +1,17 @@ +The standard *Product Page Extra Fields* configuration lets a website manager +pick product fields to display on the shop product page. It only supports +char and binary fields on the product template, and the displayed value is +read once when the page loads, so it never updates when the customer switches +variants. + +This module removes both limitations: + +- product variant fields can be selected, in addition to product template + fields; +- more field types are supported: numbers, dates, datetimes, dropdown + (selection) values and linked records, in addition to char and binary + fields; +- when a variant field is selected, its value refreshes live on the product + page as soon as the customer picks a different combination of attributes. + - decimal numbers are displayed with the number of decimals configured for the + selected field. diff --git a/website_sale_variant_extra_fields/readme/USAGE.md b/website_sale_variant_extra_fields/readme/USAGE.md new file mode 100644 index 0000000000..5041dc6b15 --- /dev/null +++ b/website_sale_variant_extra_fields/readme/USAGE.md @@ -0,0 +1,12 @@ +1. Go to *Website \> Configuration \> Settings* and open the + *Product Page Extra Fields* configuration of the website. +2. Add a line and pick any field of a product or of a product variant. Text, + file, number, date, date and time, dropdown, and linked record fields are + available. +3. Go to the shop and open a product page. The label and the value of each + selected field are displayed below the product details (if the field has a value on the displayed product or variant). +4. Select another variant attribute on that page. The values coming from a product.variant field are updated immediately, + while the values coming from a product.template field stay unchanged. + +A variant field with no value on the displayed variant is simply hidden, and +appears again as soon as the customer selects a variant that has a value. diff --git a/website_sale_variant_extra_fields/static/description/icon.png b/website_sale_variant_extra_fields/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/website_sale_variant_extra_fields/static/description/icon.png differ diff --git a/website_sale_variant_extra_fields/static/description/index.html b/website_sale_variant_extra_fields/static/description/index.html new file mode 100644 index 0000000000..442f2dbffb --- /dev/null +++ b/website_sale_variant_extra_fields/static/description/index.html @@ -0,0 +1,471 @@ + + + + + +Website Sale Variant Extra Field + + + +
+ + + +Odoo Community Association + +
+

Website Sale Variant Extra Field

+ +

Beta License: LGPL-3 OCA/e-commerce Translate me on Weblate Try me on Runboat

+

The standard Product Page Extra Fields configuration lets a website +manager pick product fields to display on the shop product page. It only +supports char and binary fields on the product template, and the +displayed value is read once when the page loads, so it never updates +when the customer switches variants.

+

This module removes both limitations:

+
    +
  • product variant fields can be selected, in addition to product +template fields;
  • +
  • more field types are supported: numbers, dates, datetimes, dropdown +(selection) values and linked records, in addition to char and binary +fields;
  • +
  • when a variant field is selected, its value refreshes live on the +product page as soon as the customer picks a different combination of +attributes.
      +
    • decimal numbers are displayed with the number of decimals configured +for the selected field.
    • +
    +
  • +
+

Table of contents

+ +
+

Usage

+
    +
  1. Go to Website > Configuration > Settings and open the Product Page +Extra Fields configuration of the website.
  2. +
  3. Add a line and pick any field of a product or of a product variant. +Text, file, number, date, date and time, dropdown, and linked record +fields are available.
  4. +
  5. Go to the shop and open a product page. The label and the value of +each selected field are displayed below the product details (if the +field has a value on the displayed product or variant).
  6. +
  7. Select another variant attribute on that page. The values coming from +a product.variant field are updated immediately, while the values +coming from a product.template field stay unchanged.
  8. +
+

A variant field with no value on the displayed variant is simply hidden, +and appears again as soon as the customer selects a variant that has a +value.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/e-commerce project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/website_sale_variant_extra_fields/static/src/js/website_sale_variant_extra_field.esm.js b/website_sale_variant_extra_fields/static/src/js/website_sale_variant_extra_field.esm.js new file mode 100644 index 0000000000..893c8fa0b0 --- /dev/null +++ b/website_sale_variant_extra_fields/static/src/js/website_sale_variant_extra_field.esm.js @@ -0,0 +1,30 @@ +/* Copyright 2026 Camptocamp SA + * License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). */ + +import {patch} from "@web/core/utils/patch"; +import {WebsiteSale} from "@website_sale/interactions/website_sale"; + +patch(WebsiteSale.prototype, { + _onChangeCombination(ev, parent, combination) { + const res = super._onChangeCombination(...arguments); + const extraFields = combination.variant_extra_fields; + if (!extraFields) { + return res; + } + const container = document.querySelector("#o_wsale_extra_fields"); + if (!container) { + return res; + } + for (const [name, value] of Object.entries(extraFields)) { + const fieldEl = container.querySelector(`[data-extra-field="${name}"]`); + const valueEl = fieldEl?.querySelector(".o_wsale_extra_field_value"); + if (!valueEl) { + continue; + } + // Values are rendered server side by the ir.qweb.field converters. + valueEl.innerHTML = value; + fieldEl.classList.toggle("d-none", !value); + } + return res; + }, +}); diff --git a/website_sale_variant_extra_fields/tests/__init__.py b/website_sale_variant_extra_fields/tests/__init__.py new file mode 100644 index 0000000000..0a7c086cb0 --- /dev/null +++ b/website_sale_variant_extra_fields/tests/__init__.py @@ -0,0 +1 @@ +from . import test_website_sale_variant_extra_fields diff --git a/website_sale_variant_extra_fields/tests/test_website_sale_variant_extra_fields.py b/website_sale_variant_extra_fields/tests/test_website_sale_variant_extra_fields.py new file mode 100644 index 0000000000..4a364e1029 --- /dev/null +++ b/website_sale_variant_extra_fields/tests/test_website_sale_variant_extra_fields.py @@ -0,0 +1,278 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import base64 +from datetime import date + +from odoo.fields import Command +from odoo.tests import TransactionCase, tagged +from odoo.tools import format_date + +from odoo.addons.website_sale.tests.common import MockRequest + +DUMMY_IMAGE = base64.b64encode( + base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8Dw" + "HwAFAAH/q842iQAAAABJRU5ErkJggg==" + ) +) + +NEW_TTYPES = [ + "integer", + "float", + "date", + "datetime", + "selection", + "many2one", + "one2many", + "many2many", +] + + +@tagged("post_install", "-at_install") +class TestWebsiteSaleVariantExtraField(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.website = cls.env["website"].get_current_website() + # Start from a clean configuration: the database may already have extra + # fields configured, which would pollute the payload assertions. + cls.website.shop_extra_field_ids.unlink() + cls.attribute = cls.env["product.attribute"].create( + {"name": "Test Attribute", "create_variant": "always"} + ) + cls.value_1 = cls.env["product.attribute.value"].create( + {"name": "Value 1", "attribute_id": cls.attribute.id} + ) + cls.value_2 = cls.env["product.attribute.value"].create( + {"name": "Value 2", "attribute_id": cls.attribute.id} + ) + cls.product_tmpl = cls.env["product.template"].create( + { + "name": "Test Product", + "is_published": True, + "list_price": 100.0, + "attribute_line_ids": [ + Command.create( + { + "attribute_id": cls.attribute.id, + "value_ids": [ + Command.set([cls.value_1.id, cls.value_2.id]) + ], + } + ) + ], + } + ) + cls.variant_1, cls.variant_2 = cls.product_tmpl.product_variant_ids[:2] + cls.variant_1.default_code = "REF-1" + cls.variant_2.default_code = "REF-2" + + def _create_extra_field(self, model, name): + field = self.env["ir.model.fields"]._get(model, name) + self.assertTrue(field, f"No ir.model.fields record for {model}.{name}") + return self.env["website.sale.extra.field"].create( + {"website_id": self.website.id, "field_id": field.id} + ) + + def _field_id_domain(self): + return list(self.env["website.sale.extra.field"]._fields["field_id"].domain) + + def test_field_id_domain_accepts_variants_and_new_types(self): + domain = self._field_id_domain() + models = next(cond[2] for cond in domain if cond[0] == "model_id.model") + self.assertEqual(sorted(models), ["product.product", "product.template"]) + ttypes = next(cond[2] for cond in domain if cond[0] == "ttype") + for ttype in ["char", "binary"] + NEW_TTYPES: + self.assertIn(ttype, ttypes) + + def test_field_id_domain_selects_variant_fields(self): + IrModelFields = self.env["ir.model.fields"] + selectable = IrModelFields.search(self._field_id_domain()) + self.assertIn(IrModelFields._get("product.product", "default_code"), selectable) + self.assertIn(IrModelFields._get("product.product", "volume"), selectable) + self.assertIn(IrModelFields._get("product.template", "name"), selectable) + + def test_is_variant_field(self): + variant_extra_field = self._create_extra_field( + "product.product", "default_code" + ) + template_extra_field = self._create_extra_field("product.template", "name") + self.assertTrue(variant_extra_field.is_variant_field) + self.assertFalse(template_extra_field.is_variant_field) + + def test_source_record_depends_on_field_model(self): + variant_extra_field = self._create_extra_field( + "product.product", "default_code" + ) + template_extra_field = self._create_extra_field("product.template", "name") + self.assertEqual( + variant_extra_field._get_source_record(self.product_tmpl, self.variant_1), + self.variant_1, + ) + self.assertEqual( + template_extra_field._get_source_record(self.product_tmpl, self.variant_1), + self.product_tmpl, + ) + + def test_render_char_field(self): + extra_field = self._create_extra_field("product.product", "default_code") + self.assertEqual(extra_field._render_value(self.variant_1), "REF-1") + + def test_render_integer_field(self): + self.variant_1.sequence = 42 + extra_field = self._create_extra_field("product.product", "sequence") + self.assertEqual(extra_field._render_value(self.variant_1), "42") + + def test_render_float_field_uses_field_digits(self): + self.env.ref("product.decimal_volume").digits = 3 + self.variant_1.volume = 1.5 + extra_field = self._create_extra_field("product.product", "volume") + self.assertEqual(extra_field._get_render_options()["precision"], 3) + self.assertEqual(extra_field._render_value(self.variant_1), "1.500") + + def test_render_date_field(self): + deadline = date(2026, 1, 15) + self.env["mail.activity"].create( + { + "res_model_id": self.env["ir.model"]._get_id("product.product"), + "res_id": self.variant_1.id, + "activity_type_id": self.env.ref("mail.mail_activity_data_todo").id, + "date_deadline": deadline, + "summary": "Test activity", + } + ) + extra_field = self._create_extra_field( + "product.product", "activity_date_deadline" + ) + self.assertEqual( + extra_field._render_value(self.variant_1), + format_date(self.env, deadline), + ) + + def test_render_datetime_field(self): + extra_field = self._create_extra_field("product.product", "create_date") + rendered = extra_field._render_value(self.variant_1) + self.assertTrue(rendered) + self.assertNotEqual(rendered, str(self.variant_1.create_date)) + + def test_render_selection_field(self): + extra_field = self._create_extra_field("product.product", "type") + rendered = extra_field._render_value(self.variant_1) + labels = dict( + self.env["product.product"] + ._fields["type"] + .get_description(self.env)["selection"] + ) + self.assertEqual(rendered, labels[self.variant_1.type]) + # The label is displayed, not the raw technical value. + self.assertNotEqual(rendered, self.variant_1.type) + + def test_render_many2one_field(self): + extra_field = self._create_extra_field("product.product", "product_tmpl_id") + self.assertEqual( + extra_field._render_value(self.variant_1), self.product_tmpl.display_name + ) + + def test_render_many2many_field(self): + tags = self.env["product.tag"].create([{"name": "Tag A"}, {"name": "Tag B"}]) + self.variant_1.additional_product_tag_ids = tags + extra_field = self._create_extra_field( + "product.product", "additional_product_tag_ids" + ) + self.assertEqual(extra_field._render_value(self.variant_1), "Tag A, Tag B") + + def test_render_one2many_field(self): + pricelist_1 = self.env["product.pricelist"].create({"name": "Test Pricelist A"}) + pricelist_2 = self.env["product.pricelist"].create({"name": "Test Pricelist B"}) + rule_1, rule_2 = self.env["product.pricelist.item"].create( + [ + { + "pricelist_id": pricelist_1.id, + "applied_on": "0_product_variant", + "product_id": self.variant_1.id, + "compute_price": "fixed", + "fixed_price": 50.0, + }, + { + "pricelist_id": pricelist_2.id, + "applied_on": "0_product_variant", + "product_id": self.variant_1.id, + "compute_price": "fixed", + "fixed_price": 60.0, + }, + ] + ) + extra_field = self._create_extra_field("product.product", "pricelist_rule_ids") + self.assertEqual( + extra_field._render_value(self.variant_1), + ", ".join((rule_1 + rule_2).mapped("display_name")), + ) + + def test_render_binary_field(self): + self.variant_1.image_variant_1920 = DUMMY_IMAGE + extra_field = self._create_extra_field("product.product", "image_variant_1920") + self.assertIn( + f"/web/content/product.product/{self.variant_1.id}" + "/image_variant_1920?download=1", + extra_field._render_value(self.variant_1), + ) + + def test_render_empty_value(self): + self.variant_1.default_code = False + extra_field = self._create_extra_field("product.product", "default_code") + self.assertEqual(extra_field._render_value(self.variant_1), "") + + # Requirement 2: recompute on variant change + + def test_combination_info_renders_current_variant_value(self): + self._create_extra_field("product.product", "default_code") + with MockRequest(self.env, website=self.website): + info_1 = self.product_tmpl._get_combination_info( + product_id=self.variant_1.id + ) + info_2 = self.product_tmpl._get_combination_info( + product_id=self.variant_2.id + ) + self.assertEqual(info_1["variant_extra_fields"], {"default_code": "REF-1"}) + self.assertEqual(info_2["variant_extra_fields"], {"default_code": "REF-2"}) + + def test_combination_info_without_variant_extra_field(self): + self._create_extra_field("product.template", "name") + with MockRequest(self.env, website=self.website): + info = self.product_tmpl._get_combination_info(product_id=self.variant_1.id) + self.assertNotIn("variant_extra_fields", info) + + def test_combination_info_keeps_empty_value_key(self): + self.variant_2.default_code = False + self._create_extra_field("product.product", "default_code") + with MockRequest(self.env, website=self.website): + info = self.product_tmpl._get_combination_info(product_id=self.variant_2.id) + # The key must stay in the payload so that the client side refresh can + # hide the value that is not set on the newly selected variant. + self.assertEqual(info["variant_extra_fields"], {"default_code": ""}) + + def test_block_displayed_for_variant_field_without_value(self): + self.variant_1.default_code = False + self._create_extra_field("product.product", "default_code") + self.assertTrue( + self.website.shop_extra_field_ids._has_content_to_display( + self.product_tmpl, self.variant_1 + ) + ) + + def test_block_hidden_for_template_field_without_value(self): + self._create_extra_field("product.template", "image_1920") + self.assertFalse( + self.website.shop_extra_field_ids._has_content_to_display( + self.product_tmpl, self.variant_1 + ) + ) + + def test_block_displayed_for_template_field_with_value(self): + self._create_extra_field("product.template", "name") + self.assertTrue( + self.website.shop_extra_field_ids._has_content_to_display( + self.product_tmpl, self.variant_1 + ) + ) diff --git a/website_sale_variant_extra_fields/views/templates.xml b/website_sale_variant_extra_fields/views/templates.xml new file mode 100644 index 0000000000..7600d5d378 --- /dev/null +++ b/website_sale_variant_extra_fields/views/templates.xml @@ -0,0 +1,56 @@ + + + + +