summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
authorSimon Genin (ges) <ges@odoo.com>2020-07-30 20:21:03 +0000
committerLucas Perais (lpe) <lpe@odoo.com>2020-09-01 08:58:40 +0000
commitc121a246f16899735306266a3a12b526e08e7620 (patch)
tree489c09e57ae1c46a28c3c51d7b97f08c29917479 /addons
parente43c35d3c95c58d6d6af424d4eebf8244c83f332 (diff)
[IMP] base: Document layout preview improvements
The document layout preview is a complete defferent simplified template with its own css that replicates at best the different styles. It does not have the external layout features and lack of fidelity. The new preview actually use the real documents templates and put the result in an iframe. It now has a high fidelity, though not perfect. The goal is for a better onboarding, where clients see easely how documents will look if they had an app to generate them. Of course, the data on the document is a false invoice. Refactor all this from base to web. Task ID 2304177 closes odoo/odoo#56415 Signed-off-by: Lucas Perais (lpe) <lpe@odoo.com>
Diffstat (limited to 'addons')
-rw-r--r--addons/base_setup/views/res_config_settings_views.xml2
-rw-r--r--addons/web/__manifest__.py1
-rw-r--r--addons/web/data/report_layout.xml23
-rw-r--r--addons/web/models/__init__.py1
-rw-r--r--addons/web/models/base_document_layout.py276
-rw-r--r--addons/web/security/ir.model.access.csv1
-rw-r--r--addons/web/static/src/js/fields/field_registry.js3
-rw-r--r--addons/web/static/src/js/fields/special_fields.js64
-rw-r--r--addons/web/static/src/scss/base_document_layout.scss109
-rw-r--r--addons/web/static/src/scss/special_fields.scss55
-rw-r--r--addons/web/static/src/xml/base.xml8
-rw-r--r--addons/web/static/tests/fields/special_fields_tests.js52
-rw-r--r--addons/web/tests/__init__.py1
-rw-r--r--addons/web/tests/odoo.pngbin0 -> 5631 bytes
-rw-r--r--addons/web/tests/sweden.pngbin0 -> 1640 bytes
-rw-r--r--addons/web/tests/test_base_document_layout.py212
-rw-r--r--addons/web/views/base_document_layout_views.xml54
-rw-r--r--addons/web/views/report_templates.xml203
-rw-r--r--addons/web/views/webclient_templates.xml1
19 files changed, 942 insertions, 124 deletions
diff --git a/addons/base_setup/views/res_config_settings_views.xml b/addons/base_setup/views/res_config_settings_views.xml
index ab4002a54ab..7398104b97f 100644
--- a/addons/base_setup/views/res_config_settings_views.xml
+++ b/addons/base_setup/views/res_config_settings_views.xml
@@ -123,7 +123,7 @@
<field name="external_report_layout_id" domain="[('type','=', 'qweb')]" class="oe_inline"/>
</div>
<div class="mt8">
- <button name="%(base.action_base_document_layout_configurator)d" string="Configure Document Layout" type="action" class="oe_link" icon="fa-arrow-right"/>
+ <button name="%(web.action_base_document_layout_configurator)d" string="Configure Document Layout" type="action" class="oe_link" icon="fa-arrow-right"/>
<button name="edit_external_header" string="Edit Layout" type="object" class="oe_link" groups="base.group_no_one"/>
<button name="%(web.action_report_externalpreview)d" string="Preview Document" type="action" class="oe_link" groups="base.group_no_one"/>
</div>
diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py
index ed939d80836..0127c40e1ce 100644
--- a/addons/web/__manifest__.py
+++ b/addons/web/__manifest__.py
@@ -18,6 +18,7 @@ This module provides the core of the Odoo Web Client.
'security/ir.model.access.csv',
'views/webclient_templates.xml',
'views/report_templates.xml',
+ 'views/base_document_layout_views.xml',
'data/report_layout.xml',
],
'qweb': [
diff --git a/addons/web/data/report_layout.xml b/addons/web/data/report_layout.xml
index d2abe98c214..9200788277d 100644
--- a/addons/web/data/report_layout.xml
+++ b/addons/web/data/report_layout.xml
@@ -1,30 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
- <record id="report_layout_standard" model="report.layout">
- <field name="name">Standard</field>
- <field name="view_id" ref="web.external_layout_standard"/>
- <field name="image">/web/static/img/preview_standard.png</field>
- <field name="pdf">/web/static/pdf/preview_standard.pdf</field>
- </record>
- <record id="report_layout_background" model="report.layout">
+
+ <record id="report_layout_background" model="report.layout">
<field name="name">Background</field>
+ <field name="sequence">5</field>
<field name="view_id" ref="web.external_layout_background"/>
<field name="image">/web/static/img/preview_background.png</field>
<field name="pdf">/web/static/pdf/preview_background.pdf</field>
</record>
+
+ <record id="report_layout_standard" model="report.layout">
+ <field name="name">Light</field>
+ <field name="sequence">2</field>
+ <field name="view_id" ref="web.external_layout_standard"/>
+ <field name="image">/web/static/img/preview_standard.png</field>
+ <field name="pdf">/web/static/pdf/preview_standard.pdf</field>
+ </record>
+
<record id="report_layout_boxed" model="report.layout">
<field name="name">Boxed</field>
+ <field name="sequence">3</field>
<field name="view_id" ref="web.external_layout_boxed"/>
<field name="image">/web/static/img/preview_boxed.png</field>
<field name="pdf">/web/static/pdf/preview_boxed.pdf</field>
</record>
<record id="report_layout_clean" model="report.layout">
<field name="name">Clean</field>
+ <field name="sequence">4</field>
<field name="view_id" ref="web.external_layout_clean"/>
<field name="image">/web/static/img/preview_clean.png</field>
<field name="pdf">/web/static/pdf/preview_clean.pdf</field>
</record>
+
<record id="asset_styles_company_report" model="ir.attachment">
<field name="datas" model="res.company" eval="obj()._get_asset_style_b64()"/>
<field name="mimetype">text/scss</field>
@@ -32,5 +40,6 @@
<field name="type">binary</field>
<field name="url">/web/static/src/scss/asset_styles_company_report.scss</field>
</record>
+
</data>
</odoo>
diff --git a/addons/web/models/__init__.py b/addons/web/models/__init__.py
index 29ae76aa206..79eef9803e3 100644
--- a/addons/web/models/__init__.py
+++ b/addons/web/models/__init__.py
@@ -4,3 +4,4 @@
from . import ir_qweb
from . import ir_http
from . import models
+from . import base_document_layout
diff --git a/addons/web/models/base_document_layout.py b/addons/web/models/base_document_layout.py
new file mode 100644
index 00000000000..47ccd2114e9
--- /dev/null
+++ b/addons/web/models/base_document_layout.py
@@ -0,0 +1,276 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, tools
+
+from odoo.modules import get_resource_path
+
+try:
+ import sass as libsass
+except ImportError:
+ # If the `sass` python library isn't found, we fallback on the
+ # `sassc` executable in the path.
+ libsass = None
+
+DEFAULT_PRIMARY = '#000000'
+DEFAULT_SECONDARY = '#000000'
+
+
+class BaseDocumentLayout(models.TransientModel):
+ """
+ Customise the company document layout and display a live preview
+ """
+
+ _name = 'base.document.layout'
+ _description = 'Company Document Layout'
+
+ company_id = fields.Many2one(
+ 'res.company', default=lambda self: self.env.company, required=True)
+
+ logo = fields.Binary(related='company_id.logo', readonly=False)
+ preview_logo = fields.Binary(related='logo', string="Preview logo")
+ report_header = fields.Text(related='company_id.report_header', readonly=False)
+ report_footer = fields.Text(related='company_id.report_footer', readonly=False)
+
+ # The paper format changes won't be reflected in the preview.
+ paperformat_id = fields.Many2one(related='company_id.paperformat_id', readonly=False)
+
+ external_report_layout_id = fields.Many2one(related='company_id.external_report_layout_id', readonly=False)
+
+ font = fields.Selection(related='company_id.font', readonly=False)
+ primary_color = fields.Char(related='company_id.primary_color', readonly=False)
+ secondary_color = fields.Char(related='company_id.secondary_color', readonly=False)
+
+ custom_colors = fields.Boolean(compute="_compute_custom_colors", readonly=False)
+ logo_primary_color = fields.Char(compute="_compute_logo_colors")
+ logo_secondary_color = fields.Char(compute="_compute_logo_colors")
+
+ report_layout_id = fields.Many2one('report.layout')
+
+ # All the sanitization get disabled as we want true raw html to be passed to an iframe.
+ preview = fields.Html(compute='_compute_preview',
+ sanitize=False,
+ sanitize_tags=False,
+ sanitize_attributes=False,
+ sanitize_style=False,
+ sanitize_form=False,
+ strip_style=False,
+ strip_classes=False)
+
+ # Those following fields are required as a company to create invoice report
+ partner_id = fields.Many2one(related='company_id.partner_id', readonly=True)
+ phone = fields.Char(related='company_id.phone', readonly=True)
+ email = fields.Char(related='company_id.email', readonly=True)
+ website = fields.Char(related='company_id.website', readonly=True)
+ vat = fields.Char(related='company_id.vat', readonly=True)
+ name = fields.Char(related='company_id.name', readonly=True)
+ country_id = fields.Many2one(related="company_id.country_id", readonly=True)
+
+ @api.depends('logo_primary_color', 'logo_secondary_color', 'primary_color', 'secondary_color',)
+ def _compute_custom_colors(self):
+ for wizard in self:
+ logo_primary = wizard.logo_primary_color or ''
+ logo_secondary = wizard.logo_secondary_color or ''
+ # Force lower case on color to ensure that FF01AA == ff01aa
+ wizard.custom_colors = (
+ wizard.logo and wizard.primary_color and wizard.secondary_color
+ and not(
+ wizard.primary_color.lower() == logo_primary.lower()
+ and wizard.secondary_color.lower() == logo_secondary.lower()
+ )
+ )
+
+ @api.depends('logo')
+ def _compute_logo_colors(self):
+ for wizard in self:
+ if wizard._context.get('bin_size'):
+ wizard_for_image = wizard.with_context(bin_size=False)
+ else:
+ wizard_for_image = wizard
+ wizard.logo_primary_color, wizard.logo_secondary_color = wizard_for_image._parse_logo_colors()
+
+ @api.depends('report_layout_id', 'logo', 'font', 'primary_color', 'secondary_color', 'report_header', 'report_footer')
+ def _compute_preview(self):
+ """ compute a qweb based preview to display on the wizard """
+
+ styles = self._get_asset_style()
+
+ for wizard in self:
+ if wizard.report_layout_id:
+ preview_css = self._get_css_for_preview(styles, wizard.id)
+ ir_ui_view = wizard.env['ir.ui.view']
+ wizard.preview = ir_ui_view._render_template('web.report_invoice_wizard_preview', {'company': wizard, 'preview_css': preview_css})
+ else:
+ wizard.preview = False
+
+ @api.onchange('company_id')
+ def _onchange_company_id(self):
+ for wizard in self:
+ wizard.logo = wizard.company_id.logo
+ wizard.report_header = wizard.company_id.report_header
+ wizard.report_footer = wizard.company_id.report_footer
+ wizard.paperformat_id = wizard.company_id.paperformat_id
+ wizard.external_report_layout_id = wizard.company_id.external_report_layout_id
+ wizard.font = wizard.company_id.font
+ wizard.primary_color = wizard.company_id.primary_color
+ wizard.secondary_color = wizard.company_id.secondary_color
+ wizard_layout = wizard.env["report.layout"].search([
+ ('view_id.key', '=', wizard.company_id.external_report_layout_id.key)
+ ])
+ wizard.report_layout_id = wizard_layout or wizard_layout.search([], limit=1)
+
+ if not wizard.primary_color:
+ wizard.primary_color = wizard.logo_primary_color or DEFAULT_PRIMARY
+ if not wizard.secondary_color:
+ wizard.secondary_color = wizard.logo_secondary_color or DEFAULT_SECONDARY
+
+ @api.onchange('custom_colors')
+ def _onchange_custom_colors(self):
+ for wizard in self:
+ if wizard.logo and not wizard.custom_colors:
+ wizard.primary_color = wizard.logo_primary_color or DEFAULT_PRIMARY
+ wizard.secondary_color = wizard.logo_secondary_color or DEFAULT_SECONDARY
+
+ @api.onchange('report_layout_id')
+ def _onchange_report_layout_id(self):
+ for wizard in self:
+ wizard.external_report_layout_id = wizard.report_layout_id.view_id
+
+ @api.onchange('logo')
+ def _onchange_logo(self):
+ for wizard in self:
+ # It is admitted that if the user puts the original image back, it won't change colors
+ company = wizard.company_id
+ # at that point wizard.logo has been assigned the value present in DB
+ if wizard.logo == company.logo and company.primary_color and company.secondary_color:
+ continue
+
+ if wizard.logo_primary_color:
+ wizard.primary_color = wizard.logo_primary_color
+ if wizard.logo_secondary_color:
+ wizard.secondary_color = wizard.logo_secondary_color
+
+ def _parse_logo_colors(self, logo=None, white_threshold=225):
+ """
+ Identifies dominant colors
+
+ First resizes the original image to improve performance, then discards
+ transparent colors and white-ish colors, then calls the averaging
+ method twice to evaluate both primary and secondary colors.
+
+ :param logo: alternate logo to process
+ :param white_threshold: arbitrary value defining the maximum value a color can reach
+
+ :return colors: hex values of primary and secondary colors
+ """
+ self.ensure_one()
+ logo = logo or self.logo
+ if not logo:
+ return False, False
+
+ # The "===" gives different base64 encoding a correct padding
+ logo += b'===' if type(logo) == bytes else '==='
+ try:
+ # Catches exceptions caused by logo not being an image
+ image = tools.image_fix_orientation(tools.base64_to_image(logo))
+ except Exception:
+ return False, False
+
+ base_w, base_h = image.size
+ w = int(50 * base_w / base_h)
+ h = 50
+
+ # Converts to RGBA if no alpha detected
+ image_converted = image.convert(
+ 'RGBA') if 'A' not in image.getbands() else image
+ image_resized = image_converted.resize((w, h))
+
+ colors = []
+ for color in image_resized.getcolors(w * h):
+ if not(color[1][0] > white_threshold and
+ color[1][1] > white_threshold and
+ color[1][2] > white_threshold) and color[1][3] > 0:
+ colors.append(color)
+
+ if not colors: # May happen when the whole image is white
+ return False, False
+ primary, remaining = tools.average_dominant_color(colors)
+ secondary = tools.average_dominant_color(
+ remaining)[0] if len(remaining) > 0 else primary
+
+ # Lightness and saturation are calculated here.
+ # - If both colors have a similar lightness, the most colorful becomes primary
+ # - When the difference in lightness is too great, the brightest color becomes primary
+ l_primary = tools.get_lightness(primary)
+ l_secondary = tools.get_lightness(secondary)
+ if (l_primary < 0.2 and l_secondary < 0.2) or (l_primary >= 0.2 and l_secondary >= 0.2):
+ s_primary = tools.get_saturation(primary)
+ s_secondary = tools.get_saturation(secondary)
+ if s_primary < s_secondary:
+ primary, secondary = secondary, primary
+ elif l_secondary > l_primary:
+ primary, secondary = secondary, primary
+
+ return tools.rgb_to_hex(primary), tools.rgb_to_hex(secondary)
+
+ @api.model
+ def action_open_base_document_layout(self, action_ref=None):
+ if not action_ref:
+ action_ref = 'web.action_base_document_layout_configurator'
+ return self.env["ir.actions.actions"]._for_xml_id(action_ref)
+
+ def document_layout_save(self):
+ # meant to be overridden
+ return self.env.context.get('report_action') or {'type': 'ir.actions.act_window_close'}
+
+ def _get_asset_style(self):
+ """
+ Compile the style template. It is a qweb template expecting company ids to generate all the code in one batch.
+ We give a useless company_ids arg, but provide the PREVIEW_ID arg that will prepare the template for
+ '_get_css_for_preview' processing later.
+ :return:
+ """
+ template_style = self.env.ref('web.styles_company_report', raise_if_not_found=False)
+ if not template_style:
+ return b''
+
+ company_styles = template_style._render({
+ 'company_ids': self,
+ })
+
+ return company_styles
+
+ @api.model
+ def _get_css_for_preview(self, scss, new_id):
+ """
+ Compile the scss into css.
+ """
+ css_code = self._compile_scss(scss)
+ return css_code
+
+ @api.model
+ def _compile_scss(self, scss_source):
+ """
+ This code will compile valid scss into css.
+ Parameters are the same from odoo/addons/base/models/assetsbundle.py
+ Simply copied and adapted slightly
+ """
+
+ # No scss ? still valid, returns empty css
+ if not scss_source.strip():
+ return ""
+
+ precision = 8
+ output_style = 'expanded'
+ bootstrap_path = get_resource_path('web', 'static', 'lib', 'bootstrap', 'scss')
+
+ try:
+ return libsass.compile(
+ string=scss_source,
+ include_paths=[
+ bootstrap_path,
+ ],
+ output_style=output_style,
+ precision=precision,
+ )
+ except libsass.CompileError as e:
+ raise libsass.CompileError(e.args[0])
diff --git a/addons/web/security/ir.model.access.csv b/addons/web/security/ir.model.access.csv
index 97dd8b917b8..e253af60c76 100644
--- a/addons/web/security/ir.model.access.csv
+++ b/addons/web/security/ir.model.access.csv
@@ -1 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+"access_base_document_layout","access.base.document.layout","model_base_document_layout","base.group_system",1,1,1,0
diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js
index ef174702579..a5c8a85da0a 100644
--- a/addons/web/static/src/js/fields/field_registry.js
+++ b/addons/web/static/src/js/fields/field_registry.js
@@ -94,5 +94,6 @@ registry
// Special fields
registry
.add('timezone_mismatch', special_fields.FieldTimezoneMismatch)
- .add('report_layout', special_fields.FieldReportLayout);
+ .add('report_layout', special_fields.FieldReportLayout)
+ .add('iframe_wrapper', special_fields.IframeWrapper)
});
diff --git a/addons/web/static/src/js/fields/special_fields.js b/addons/web/static/src/js/fields/special_fields.js
index ab040daa4f0..52ef6d513f7 100644
--- a/addons/web/static/src/js/fields/special_fields.js
+++ b/addons/web/static/src/js/fields/special_fields.js
@@ -4,9 +4,11 @@ odoo.define('web.special_fields', function (require) {
var core = require('web.core');
var field_utils = require('web.field_utils');
var relational_fields = require('web.relational_fields');
+var AbstractField = require('web.AbstractField');
var FieldSelection = relational_fields.FieldSelection;
var _t = core._t;
+var _lt = core._lt;
/**
@@ -62,7 +64,7 @@ var FieldTimezoneMismatch = FieldSelection.extend({
},
/**
* Display the timezone alert
- *
+ *
* Note: timezone alert is a span that is added after $el, and $el is now a
* set of two elements
*
@@ -192,9 +194,69 @@ var FieldReportLayout = relational_fields.FieldMany2One.extend({
});
+const IframeWrapper = AbstractField.extend({
+ description: _lt("Wrap raw html within an iframe"),
+
+ // If HTML, don't forget to adjust the sanitize options to avoid stripping most of the metadata
+ supportedFieldTypes: ['text', 'html'],
+
+ template: "web.IframeWrapper",
+
+ _render() {
+
+ const spinner = this.el.querySelector('.o_iframe_wrapper_spinner');
+ const iframe = this.el.querySelector('.o_preview_iframe');
+
+ iframe.style.display = 'none';
+ spinner.style.display = 'block';
+
+ // Promise for tests
+ let resolver;
+ $(iframe).data('ready', new Promise((resolve) => {
+ resolver = resolve;
+ }));
+
+ /**
+ * Certain browser don't trigger onload events of iframe for particular cases.
+ * In our case, chrome and safari could be problematic depending on version and environment.
+ * This rather unorthodox solution replace the onload event handler. (jquery on('load') doesn't fix it)
+ */
+ const onloadReplacement = setInterval(() => {
+ const iframeDoc = iframe.contentDocument;
+ if (iframeDoc && (iframeDoc.readyState === 'complete' || iframeDoc.readyState === 'interactive')) {
+
+ /**
+ * The document.write is not recommended. It is better to manipulate the DOM through $.appendChild and
+ * others. In our case though, we deal with an iframe without src attribute and with metadata to put in
+ * head tag. If we use the usual dom methods, the iframe is automatically created with its document
+ * component containing html > head & body. Therefore, if we want to make it work that way, we would
+ * need to receive each piece at a time to append it to this document (with this.record.data and extra
+ * model fields or with an rpc). It also cause other difficulties getting attribute on the most parent
+ * nodes, parsing to HTML complex elements, etc.
+ * Therefore, document.write makes it much more trivial in our situation.
+ */
+ iframeDoc.open();
+ iframeDoc.write(this.value);
+ iframeDoc.close();
+
+ iframe.style.display = 'block';
+ spinner.style.display = 'none';
+
+ resolver();
+
+ clearInterval(onloadReplacement);
+ }
+ }, 100);
+
+ }
+
+});
+
+
return {
FieldTimezoneMismatch: FieldTimezoneMismatch,
FieldReportLayout: FieldReportLayout,
+ IframeWrapper,
};
});
diff --git a/addons/web/static/src/scss/base_document_layout.scss b/addons/web/static/src/scss/base_document_layout.scss
index bf75cb71158..4203fe2bb73 100644
--- a/addons/web/static/src/scss/base_document_layout.scss
+++ b/addons/web/static/src/scss/base_document_layout.scss
@@ -9,6 +9,7 @@
max-height: 100px;
}
.o_document_layout_colors {
+ margin-bottom: 32px;
vertical-align: middle;
display: flex;
flex-direction: row;
@@ -33,114 +34,6 @@
}
}
- .o_document_layout_preview {
- flex: 4;
- border: 1px solid;
- border-color: gray;
- margin-right: 12px;
- padding: 12px;
- display: flex;
- min-height: 420px;
- flex-direction: column;
- }
-}
-
-.o_document_layout_preview_logo img {
- padding-left: 5px;
- max-height: 45px;
-}
-
-.o_document_layout_preview_html {
- flex: 1;
- .o_readonly {
- height: 100%;
- }
- .o_preview_layout {
- display: flex;
- flex-direction: column;
- height: 100%;
- }
- .header {
- padding: 0 10px;
- border-top: 1px solid;
- h2 {
- margin: 40px 0;
- }
- .o_layout_information {
- margin-bottom: 20px;
- }
- }
- .article {
- flex: 1;
- padding: 0 10px;
- table th, table td {
- text-align: right;
- &:first-child {
- text-align: left;
- }
- }
- }
- .footer {
- border-top: 1px solid;
- }
- .o_preview_background_layout {
- background-image: url(/base/static/img/bg_background_template.jpg);
- background-size: cover;
- background-position: bottom center;
- background-repeat: no-repeat;
- .table tbody tr:nth-child(odd) {
- background-color: rgba(220, 205, 216, 0.2);
- }
- .row table tbody tr:nth-child(odd) {
- background-color: transparent !important;
- }
- .footer {
- border-bottom: 1px solid;
- }
- }
- .o_preview_clean_layout {
- .header {
- border: none;
- }
- .article table {
- border-bottom: 1px solid;
- border-top: 1px solid;
- margin: 0;
- }
- thead th {
- border-top: 1px solid;
- text-transform: uppercase;
- }
- }
- .o_preview_standard_layout {
- .o_subtotal, .o_total {
- td {
- border-top: 1px solid;
- }
- }
- }
- .o_preview_boxed_layout {
- table {
- border: 1px solid gray('700');
- thead th {
- border: 1px solid gray('700');
- text-transform: uppercase;
- }
- tbody tr td {
- border-top: none;
- border-right: 1px solid gray('700');
- }
- }
- .o_subtotal td, .o_total td {
- border-right: none;
- border-top: 1px solid gray('700');
- }
- .o_total td {
- strong, span {
- color: white;
- }
- }
- }
}
@media (max-width: 620px) {
diff --git a/addons/web/static/src/scss/special_fields.scss b/addons/web/static/src/scss/special_fields.scss
new file mode 100644
index 00000000000..cabdf919c09
--- /dev/null
+++ b/addons/web/static/src/scss/special_fields.scss
@@ -0,0 +1,55 @@
+/*
+ IFRAME WRAPPER SCSS
+ */
+
+$preview_height: 1123 + 32;
+$preview_width: 794;
+$preview_scale: 0.50;
+
+@mixin o_preview_iframe_styling($scale) {
+
+ .o_preview_iframe_wrapper {
+ padding: 0;
+ overflow: hidden;
+ width: ($preview_width * $scale) + 0px;
+ height: ($preview_height * $scale) + 0px;
+ position: relative;
+ }
+
+ .o_preview_iframe {
+ width: $preview_width + 0px;
+ height: $preview_height + 0px;
+ border: 2px solid lightgrey;
+ overflow: hidden;
+
+ padding-top: 16px;
+ padding-bottom: 16px;
+
+ -ms-zoom: 0.5;
+ -moz-transform: scale($scale);
+ -moz-transform-origin: 0 0;
+ -o-transform: scale($scale);
+ -o-transform-origin: 0 0;
+ -webkit-transform: scale($scale);
+ -webkit-transform-origin: 0 0;
+ }
+
+ .o_iframe_wrapper_spinner {
+ position: absolute;
+ left: (($preview_width * $scale) / 2) - 40 + 0px;
+ top: (($preview_height * $scale) / 2) - 40 + 0px;
+ // -40 for width/2 and height/2 of the spinner
+ }
+
+}
+
+
+@include o_preview_iframe_styling($preview_scale)
+
+@media (max-width: 1488px) {
+ @include o_preview_iframe_styling($preview_scale * 0.80)
+}
+
+@media (max-width: 600px) {
+ @include o_preview_iframe_styling($preview_scale * 0.60)
+}
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 27cc0fdc4f9..374b7715a33 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -1852,4 +1852,12 @@
</button>
</t>
+<t t-name="web.IframeWrapper">
+ <div class="o_preview_iframe_wrapper">
+ <i class="o_iframe_wrapper_spinner fa fa-spinner fa-spin fa-4x" style="color: #875a7b; z-index: 2"/>
+ <!--suppress CheckTagEmptyBody -->
+ <iframe src="about:blank" class="o_preview_iframe" style="display:none"></iframe>
+ </div>
+</t>
+
</templates>
diff --git a/addons/web/static/tests/fields/special_fields_tests.js b/addons/web/static/tests/fields/special_fields_tests.js
index bfde65abf12..6f0ea650d43 100644
--- a/addons/web/static/tests/fields/special_fields_tests.js
+++ b/addons/web/static/tests/fields/special_fields_tests.js
@@ -308,6 +308,58 @@ QUnit.module('special_fields', {
form.destroy();
});
+
+ QUnit.module('IframeWrapper');
+
+ QUnit.test('iframe_wrapper widget in form view', async function (assert) {
+
+ assert.expect(2);
+
+ this.data = {
+ report: {
+ fields: {
+ report_content: {string: "Content of report", type: "html"}
+ },
+ records: [{
+ id: 1,
+ report_content:
+ `<html>
+ <head>
+ <style>
+ body { color : rgb(255, 0, 0); }
+ </style>
+ <head>
+ <body>
+ <div class="nice_div"><p>Some content</p></div>
+ </body>
+ </html>`
+ }]
+ }
+ };
+
+ const form = await createView({
+ View: FormView,
+ model: 'report',
+ data: this.data,
+ arch: `<form><field name="report_content" widget="iframe_wrapper"/></form>`,
+ res_id: 1,
+ });
+
+ const $iframe = form.$('iframe');
+ await $iframe.data('ready');
+ const doc = $iframe.contents()[0];
+
+ assert.strictEqual($(doc).find('.nice_div').html(), '<p>Some content</p>',
+ "should have rendered a div with correct content");
+
+ assert.strictEqual($(doc).find('.nice_div p').css('color'), 'rgb(255, 0, 0)',
+ "head tag style should have been applied");
+
+ form.destroy();
+
+ });
+
+
});
});
});
diff --git a/addons/web/tests/__init__.py b/addons/web/tests/__init__.py
index 1ed329bafe1..5008bb7fabe 100644
--- a/addons/web/tests/__init__.py
+++ b/addons/web/tests/__init__.py
@@ -6,3 +6,4 @@ from . import test_js
from . import test_menu
from . import test_serving_base
from . import test_click_everywhere
+from . import test_base_document_layout
diff --git a/addons/web/tests/odoo.png b/addons/web/tests/odoo.png
new file mode 100644
index 00000000000..dd7c1e62cab
--- /dev/null
+++ b/addons/web/tests/odoo.png
Binary files differ
diff --git a/addons/web/tests/sweden.png b/addons/web/tests/sweden.png
new file mode 100644
index 00000000000..be4800025ba
--- /dev/null
+++ b/addons/web/tests/sweden.png
Binary files differ
diff --git a/addons/web/tests/test_base_document_layout.py b/addons/web/tests/test_base_document_layout.py
new file mode 100644
index 00000000000..55b324117f4
--- /dev/null
+++ b/addons/web/tests/test_base_document_layout.py
@@ -0,0 +1,212 @@
+import os
+from PIL import Image
+from functools import partial
+
+from odoo.tests import TransactionCase, tagged, Form
+from odoo.tools import frozendict, image_to_base64, hex_to_rgb
+
+
+dir_path = os.path.dirname(os.path.realpath(__file__))
+_file_cache = {}
+
+
+class TestBaseDocumentLayoutHelpers(TransactionCase):
+ #
+ # Public
+ #
+ def setUp(self):
+ super(TestBaseDocumentLayoutHelpers, self).setUp()
+ self.color_fields = ['primary_color', 'secondary_color']
+ self.company = self.env.company
+ self.css_color_error = 0
+ self._set_templates_and_layouts()
+ self._set_images()
+
+ def assertColors(self, checked_obj, expected):
+ _expected_getter = expected.get if isinstance(expected, dict) else partial(getattr, expected)
+ for fname in self.color_fields:
+ color1 = getattr(checked_obj, fname)
+ color2 = _expected_getter(fname)
+ if self.css_color_error:
+ self._compare_colors_rgb(color1, color2)
+ else:
+ self.assertEqual(color1, color2)
+
+ #
+ # Private
+ #
+ def _compare_colors_rgb(self, color1, color2):
+ self.assertEqual(bool(color1), bool(color2))
+ if not color1:
+ return
+ color1 = hex_to_rgb(color1)
+ color2 = hex_to_rgb(color2)
+ self.assertEqual(len(color1), len(color2))
+ for i in range(len(color1)):
+ self.assertAlmostEqual(color1[i], color2[i], delta=self.css_color_error)
+
+ def _get_images_for_test(self):
+ return ['sweden.png', 'odoo.png']
+
+ def _set_images(self):
+ for fname in self._get_images_for_test():
+ fname_split = fname.split('.')
+ if not fname_split[0] in _file_cache:
+ with Image.open(os.path.join(dir_path, fname), 'r') as img:
+ base64_img = image_to_base64(img, 'PNG')
+ primary, secondary = self.env['base.document.layout'].create(
+ {})._parse_logo_colors(base64_img)
+ _img = frozendict({
+ 'img': base64_img,
+ 'colors': {
+ 'primary_color': primary,
+ 'secondary_color': secondary,
+ },
+ })
+ _file_cache[fname_split[0]] = _img
+ self.company_imgs = frozendict(_file_cache)
+
+ def _set_templates_and_layouts(self):
+ self.layout_template1 = self.env['ir.ui.view'].create({
+ 'name': 'layout_template1',
+ 'key': 'web.layout_template1',
+ 'type': 'qweb',
+ 'arch': '''<div></div>''',
+ })
+ self.env['ir.model.data'].create({
+ 'name': self.layout_template1.name,
+ 'model': 'ir.ui.view',
+ 'module': 'web',
+ 'res_id': self.layout_template1.id,
+ })
+ self.default_colors = {
+ 'primary_color': '#000000',
+ 'secondary_color': '#000000',
+ }
+ self.report_layout1 = self.env['report.layout'].create({
+ 'view_id': self.layout_template1.id,
+ 'name': 'report_%s' % self.layout_template1.name,
+ })
+ self.layout_template2 = self.env['ir.ui.view'].create({
+ 'name': 'layout_template2',
+ 'key': 'web.layout_template2',
+ 'type': 'qweb',
+ 'arch': '''<div></div>''',
+ })
+ self.env['ir.model.data'].create({
+ 'name': self.layout_template2.name,
+ 'model': 'ir.ui.view',
+ 'module': 'web',
+ 'res_id': self.layout_template2.id,
+ })
+ self.report_layout2 = self.env['report.layout'].create({
+ 'view_id': self.layout_template2.id,
+ 'name': 'report_%s' % self.layout_template2.name,
+ })
+
+
+@tagged('document_layout')
+class TestBaseDocumentLayout(TestBaseDocumentLayoutHelpers):
+ # Logo change Tests
+ def test_company_no_color_change_logo(self):
+ """When neither a logo nor the colors are set
+ The wizard displays the colors of the report layout
+ Changing logo means the colors on the wizard change too
+ Emptying the logo works and doesn't change the colors"""
+ self.company.write({
+ 'primary_color': False,
+ 'secondary_color': False,
+ 'logo': False,
+ 'external_report_layout_id': self.env.ref('web.layout_template1').id,
+ 'paperformat_id': self.env.ref('base.paperformat_us').id,
+ })
+ default_colors = self.default_colors
+ with Form(self.env['base.document.layout']) as doc_layout:
+ self.assertColors(doc_layout, default_colors)
+ self.assertEqual(doc_layout.company_id, self.company)
+ doc_layout.logo = self.company_imgs['sweden']['img']
+
+ self.assertColors(doc_layout, self.company_imgs['sweden']['colors'])
+
+ doc_layout.logo = ''
+ self.assertColors(doc_layout, self.company_imgs['sweden']['colors'])
+ self.assertEqual(doc_layout.logo, '')
+
+ def test_company_no_color_but_logo_change_logo(self):
+ """When company colors are not set, but a logo is,
+ the wizard displays the computed colors from the logo"""
+ self.company.write({
+ 'primary_color': '#ff0080',
+ 'secondary_color': '#00ff00',
+ 'logo': self.company_imgs['sweden']['img'],
+ 'paperformat_id': self.env.ref('base.paperformat_us').id,
+ })
+
+ with Form(self.env['base.document.layout']) as doc_layout:
+ self.assertColors(doc_layout, self.company)
+ doc_layout.logo = self.company_imgs['odoo']['img']
+ self.assertColors(doc_layout, self.company_imgs['odoo']['colors'])
+
+ def test_company_colors_change_logo(self):
+ """changes of the logo implies displaying the new computed colors"""
+ self.company.write({
+ 'primary_color': '#ff0080',
+ 'secondary_color': '#00ff00',
+ 'logo': False,
+ 'paperformat_id': self.env.ref('base.paperformat_us').id,
+ })
+
+ with Form(self.env['base.document.layout']) as doc_layout:
+ self.assertColors(doc_layout, self.company)
+ doc_layout.logo = self.company_imgs['odoo']['img']
+ self.assertColors(doc_layout, self.company_imgs['odoo']['colors'])
+
+ def test_company_colors_and_logo_change_logo(self):
+ """The colors of the company may differ from the one the logo computes
+ Opening the wizard in these condition displays the company's colors
+ When the logo changes, colors must change according to the logo"""
+ self.company.write({
+ 'primary_color': '#ff0080',
+ 'secondary_color': '#00ff00',
+ 'logo': self.company_imgs['sweden']['img'],
+ 'paperformat_id': self.env.ref('base.paperformat_us').id,
+ })
+
+ with Form(self.env['base.document.layout']) as doc_layout:
+ self.assertColors(doc_layout, self.company)
+ doc_layout.logo = self.company_imgs['odoo']['img']
+ self.assertColors(doc_layout, self.company_imgs['odoo']['colors'])
+
+ # Layout change tests
+ def test_company_colors_reset_colors(self):
+ """Reset the colors when they differ from the ones originally
+ computed from the company logo"""
+ self.company.write({
+ 'primary_color': '#ff0080',
+ 'secondary_color': '#00ff00',
+ 'logo': self.company_imgs['sweden']['img'],
+ 'paperformat_id': self.env.ref('base.paperformat_us').id,
+ })
+
+ with Form(self.env['base.document.layout']) as doc_layout:
+ self.assertColors(doc_layout, self.company)
+ doc_layout.primary_color = doc_layout.logo_primary_color
+ doc_layout.secondary_color = doc_layout.logo_secondary_color
+ self.assertColors(doc_layout, self.company_imgs['sweden']['colors'])
+
+ # /!\ This case is NOT supported, and probably not supportable
+ # res.partner resizes manu-militari the image it is given
+ # so res.company._get_logo differs from res.partner.[default image]
+ # def test_company_no_colors_default_logo_and_layout_change_layout(self):
+ # """When the default YourCompany logo is set, and no colors are set on company:
+ # change wizard's color according to template"""
+ # self.company.write({
+ # 'primary_color': False,
+ # 'secondary_color': False,
+ # 'external_report_layout_id': self.layout_template1.id,
+ # })
+ # default_colors = self.default_colors
+ # with Form(self.env['base.document.layout']) as doc_layout:
+ # self.assertColors(doc_layout, default_colors)
+ # doc_layout.report_layout_id = self.report_layout2
+ # self.assertColors(doc_layout, self.report_layout2)
diff --git a/addons/web/views/base_document_layout_views.xml b/addons/web/views/base_document_layout_views.xml
new file mode 100644
index 00000000000..7f9e5c033ff
--- /dev/null
+++ b/addons/web/views/base_document_layout_views.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+ <record id="view_base_document_layout" model="ir.ui.view">
+ <field name="name">Document Layout</field>
+ <field name="model">base.document.layout</field>
+ <field name="arch" type="xml">
+ <form class="o_document_layout">
+ <group>
+ <group class="o_document_layout_company">
+ <field name="company_id" invisible="1"/>
+ <field name="external_report_layout_id" invisible="1" />
+ <field name="logo_primary_color" invisible="1" />
+ <field name="logo_secondary_color" invisible="1" />
+ <field name="report_layout_id" widget="radio" string="Layout"/>
+ <field name="logo" widget="image"/>
+ <label for="primary_color" string="Colors" />
+ <div class="o_document_layout_colors">
+ <field name="primary_color" widget="color"/>
+ <field name="secondary_color" widget="color"/>
+ <field name="custom_colors" class="d-none" />
+ <button class="btn btn-link" title="Reset to logo colors" attrs="{'invisible': [('custom_colors', '=', False)]}">
+ <label for="custom_colors" class="fa fa-refresh" string="" />
+ </button>
+ </div>
+ <field name="font" widget="font"/>
+ <field name="report_header" widget="char" placeholder="e.g. Global Business Solutions" />
+ <field name="report_footer" string="Footer" placeholder="e.g. Opening hours, bank accounts (one per line)" />
+ <field name="paperformat_id" required="1" />
+ </group>
+ <div>
+ <field name="preview" widget="iframe_wrapper" />
+ </div>
+ </group>
+ <footer>
+ <button string="Save" class="btn-primary" type="object" name="document_layout_save"/>
+ <button special="cancel" string="Cancel" />
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="action_base_document_layout_configurator" model="ir.actions.act_window">
+ <field name="type">ir.actions.act_window</field>
+ <field name="name">Configure your document layout</field>
+ <field name="view_mode">form</field>
+ <field name="target">new</field>
+ <field name="res_model">base.document.layout</field>
+ <field name="view_id" ref="web.view_base_document_layout"/>
+ </record>
+
+ </data>
+</odoo>
+
diff --git a/addons/web/views/report_templates.xml b/addons/web/views/report_templates.xml
index 2e59fce6bff..0dda4eaab88 100644
--- a/addons/web/views/report_templates.xml
+++ b/addons/web/views/report_templates.xml
@@ -4,18 +4,13 @@
<template id="report_assets_common">
<t t-call="web._assets_helpers">
- <!-- FIXME web_editor ? -->
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/bootstrap_overridden_report.scss"/>
- <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/bootstrap_overridden.scss"/>
</t>
<t t-call="web._assets_bootstrap"/>
<link href="/base/static/src/css/description.css" rel="stylesheet" type="text/css"/>
- <!-- FIXME web_editor ? -->
- <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/web_editor.common.scss"/>
-
<link href="/web/static/lib/fontawesome/css/font-awesome.css" rel="stylesheet" type="text/css"/>
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/fonts.scss"/>
@@ -66,6 +61,69 @@
</html>
</template>
+ <template id="report_preview_layout" name="Report layout">&lt;!DOCTYPE html&gt;
+ <html t-att-lang="lang and lang.replace('_', '-')"
+ t-att-data-report-margin-top="data_report_margin_top"
+ t-att-data-report-header-spacing="data_report_header_spacing"
+ t-att-data-report-dpi="data_report_dpi"
+ t-att-data-report-landscape="data_report_landscape"
+ t-att-web-base-url="web_base_url"
+ >
+ <head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="initial-scale=1"/>
+ <title><t t-esc="title or 'Odoo Report'"/></title>
+ <t t-call-assets="web.report_assets_common" t-js="false"/>
+ <t t-call-assets="web.assets_common" t-css="false"/>
+ <style>
+ <t t-raw="preview_css"/>
+
+ /**
+ Some css is overridden as it doesn't work properly in the preview.
+ Before generating 'real' reports, a lot of processing is applied. It is therefore quite
+ complicated to have an almost exact replica of the pdf report.
+ This changes down here are hacks to make the preview look as good as possible.
+ **/
+
+ /** The footers are not at the bottom of the page, so we push them down. **/
+ .o_background_footer {
+ margin-top: 270px !important;
+ }
+ .o_standard_footer {
+ margin-top: 190px !important;
+ }
+ .o_boxed_footer {
+ margin-top: 270px !important;
+ }
+ .o_clean_footer {
+ margin-top: 160px !important;
+ }
+
+ /** Avoid image overflowing and creating a scrollbar **/
+ .o_background_header {
+ min-width: initial !important;
+ }
+
+ /** This rule is overridden in web/report.scss to hack around whtml2pdf
+ Simply set it to its default value so it works properly in preview **/
+ .col-auto{
+ -webkit-box-flex: 0 !important;
+ -webkit-flex: 0 0 auto !important;
+ flex: 0 0 auto !important;
+ width: auto !important;
+ max-width: 100% !important;
+ }
+
+ </style>
+ </head>
+ <body t-att-class="'container' if not full_width else 'container-fluid'">
+ <div id="wrapwrap">
+ <t t-raw="0"/>
+ </div>
+ </body>
+ </html>
+ </template>
+
<template id="html_container">
<t t-set="body_classname" t-value="'container'"/>
<t t-call="web.report_layout">
@@ -73,6 +131,139 @@
</t>
</template>
+ <template id="html_preview_container">
+ <t t-set="body_classname" t-value="'container'"/>
+ <t t-call="web.report_preview_layout">
+ <t t-raw="0"/>
+ </t>
+ </template>
+
+
+ <template id="report_invoice_wizard_preview">
+ <t t-call="web.html_preview_container">
+ <t t-call="web.external_layout">
+ <div class="pt-5">
+ <div class="address row">
+ <div name="address" class="col-md-5 ml-auto">
+ <address>
+ <address class="mb-0" itemscope="itemscope"
+ itemtype="http://schema.org/Organization">
+ <div>
+ <span itemprop="name">Deco Addict</span>
+ </div>
+ <div itemprop="address" itemscope="itemscope"
+ itemtype="http://schema.org/PostalAddress">
+ <div class="d-flex align-items-baseline">
+ <span class="w-100 o_force_ltr" itemprop="streetAddress">77 Santa Barbara
+ Rd<br/>Pleasant Hill CA 94523<br/>United States</span>
+ </div>
+ </div>
+ </address>
+ </address>
+ </div>
+ </div>
+ </div>
+ <div class="page">
+ <h2>
+ <span>Invoice</span>
+ <span>INV/2020/07/0003</span>
+ </h2>
+ <div id="informations" class="row mt32 mb32">
+ <div class="col-auto mw-100 mb-2" name="invoice_date">
+ <strong>Invoice Date:</strong>
+ <p class="m-0">07/08/2020</p>
+ </div>
+ <div class="col-auto mw-100 mb-2" name="due_date">
+ <strong>Due Date:</strong>
+ <p class="m-0">08/07/2020</p>
+ </div>
+ </div>
+ <table class="table table-sm o_main_table" name="invoice_line_table">
+ <thead>
+ <tr>
+ <th name="th_description" class="text-left"><span>Description</span></th>
+ <th name="th_quantity" class="text-right"><span>Quantity</span></th>
+ <th name="th_priceunit" class="text-right d-none d-md-table-cell"><span>Unit Price</span></th>
+ <th name="th_taxes" class="text-left d-none d-md-table-cell"><span>Taxes</span></th>
+ <th name="th_subtotal" class="text-right">
+ <span>Amount</span>
+ </th>
+ </tr>
+ </thead>
+ <tbody class="invoice_tbody">
+ <tr>
+ <td name="account_invoice_line_name"><span>[FURN_8999] Three-Seat Sofa<br/>
+ Three Seater Sofa with Lounger in Steel Grey Colour</span></td>
+ <td class="text-right">
+ <span>5.000</span>
+ </td>
+ <td class="text-right d-none d-md-table-cell">
+ <span class="text-nowrap">1,500.00</span>
+ </td>
+ <td class="text-left d-none d-md-table-cell">
+ <span id="line_tax_ids">15.00%</span>
+ </td>
+ <td class="text-right o_price_total">
+ <span class="text-nowrap">$ <span class="oe_currency_value">7,500.00</span></span>
+ </td>
+ </tr>
+ <tr>
+ <td name="account_invoice_line_name"><span>[FURN_8220] Four Person Desk<br/>
+ Four person modern office workstation</span></td>
+ <td class="text-right">
+ <span>5.000</span>
+ </td>
+ <td class="text-right d-none d-md-table-cell">
+ <span class="text-nowrap">23,500.00</span>
+ </td>
+ <td class="text-left d-none d-md-table-cell">
+ <span id="line_tax_ids">15.00%</span>
+ </td>
+ <td class="text-right o_price_total">
+ <span class="text-nowrap">$ <span class="oe_currency_value">117,500.00</span></span>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <div class="clearfix">
+ <div id="total" class="row">
+ <div class="col-sm-7 col-md-6 ml-auto">
+ <table class="table table-sm" style="page-break-inside: avoid;">
+ <tbody><tr class="border-black o_subtotal" style="">
+ <td><strong>Subtotal</strong></td>
+ <td class="text-right">
+ <span>$ <span class="oe_currency_value">125,000.00</span></span>
+ </td>
+ </tr>
+ <tr style="">
+ <td><span class="text-nowrap">Tax 15%</span></td>
+ <td class="text-right o_price_total">
+ <span class="text-nowrap">$ 18,750.00</span>
+ </td>
+ </tr>
+ <tr class="border-black o_total">
+ <td><strong>Total</strong></td>
+ <td class="text-right">
+ <span class="text-nowrap">$ <span class="oe_currency_value">
+ 143,750.00</span></span>
+ </td>
+ </tr>
+ </tbody></table>
+ </div>
+ </div>
+ </div>
+ <p>
+ Please use the following communication for your payment : <b><span>
+ INV/2020/07/0003</span></b>
+ </p>
+ <p name="payment_term">
+ <span>Payment terms: 30 Days</span>
+ </p>
+ </div>
+ </t>
+ </t>
+ </template>
+
<template id="minimal_layout">
&lt;!DOCTYPE html&gt;
<t t-raw="'&lt;base href=%s&gt;' % base_url"/>
@@ -425,7 +616,7 @@
<t t-set="primary" t-value="company.primary_color or 'black'" />
<t t-set="secondary" t-value="company.secondary_color or 'black'" />
<t t-set="layout" t-value="company.external_report_layout_id.key or 'web.external_layout_standard'" />
- .o_company_<t t-esc='company.id'/>_layout {
+ .o_company_<t t-esc='company.id'/>_layout {
font-family: '<t t-esc="font" />';
<t t-if="layout == 'web.external_layout_background'">
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index 34c9dd5aca0..6cdde6286b6 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -231,6 +231,7 @@
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/attachment_preview.scss"/>
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/notification.scss"/>
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/base_document_layout.scss"/>
+ <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/special_fields.scss"/>
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/ribbon.scss"/>
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/base_settings.scss"/>
<script type="text/javascript" src="/base/static/src/js/res_config_settings.js"></script>