diff --git a/addons/portal/static/src/scss/portal.scss b/addons/portal/static/src/scss/portal.scss
index 350df828cd1..001b2b3da33 100644
--- a/addons/portal/static/src/scss/portal.scss
+++ b/addons/portal/static/src/scss/portal.scss
@@ -4,7 +4,7 @@
///
// ====== Variables =========
-$-navbar-height: $nav-link-height !default;
+$o-theme-navbar-logo-height: $nav-link-height !default;
// Portal toolbar (filters, search bar)
$o-portal-mobile-toolbar: true; // Enable/Disable custom design
@@ -42,15 +42,23 @@ $o-portal-use-default-colors: $body-bg == $o-portal-default-body-bg;
header {
.navbar-brand {
flex: 0 0 auto;
+ max-width: 75%;
&.logo {
padding-top: 0;
padding-bottom: 0;
img {
+ // object-fit does not work on IE but is only used as a fallback
+ object-fit: contain;
+ display: block;
width: auto;
- height: $-navbar-height;
- max-width: none;
+ height: $o-theme-navbar-logo-height;
+
+ @include media-breakpoint-down(sm) {
+ height: auto;
+ max-height: min($o-theme-navbar-logo-height, 5rem);
+ }
}
}
}
@@ -64,7 +72,6 @@ header {
ul.nav > li {
&.divider {
display: none;
- height: $-navbar-height;
border-right: 1px solid $nav-divider-color;
}
diff --git a/addons/portal/static/src/scss/primary_variables.scss b/addons/portal/static/src/scss/primary_variables.scss
index 68a1664e93c..da994350c97 100644
--- a/addons/portal/static/src/scss/primary_variables.scss
+++ b/addons/portal/static/src/scss/primary_variables.scss
@@ -1,3 +1,5 @@
$o-portal-default-body-bg: #FCFCFC;
+$o-theme-navbar-logo-height: null;
+
$o-theme-btn-icon-hover-decoration: none;
diff --git a/addons/web/static/src/scss/utils.scss b/addons/web/static/src/scss/utils.scss
index 5ac917fcd75..49485067037 100644
--- a/addons/web/static/src/scss/utils.scss
+++ b/addons/web/static/src/scss/utils.scss
@@ -177,11 +177,11 @@
// Mixin which allows to extend the BS4 bg-variant mixin
@mixin o-bg-color-extension($color, $text-color, $with-muted) {}
-// Function to remove all falsy values of a map
+// Function to remove all null values of a map
@function o-map-omit($map) {
$-map: ();
@each $key, $value in $map {
- @if $value {
+ @if $value != null {
$-map: map-merge($-map, (
$key: $value,
));
diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py
index d4ff41de09d..83320fef24f 100644
--- a/addons/web_editor/controllers/main.py
+++ b/addons/web_editor/controllers/main.py
@@ -4,20 +4,18 @@ import base64
import io
import json
import logging
-import os
import re
import time
-import uuid
import werkzeug.wrappers
from PIL import Image, ImageFont, ImageDraw
-from lxml import etree, html
+from lxml import etree
from odoo.http import request
from odoo import http, tools
-from odoo.modules.module import get_resource_path, get_module_path
logger = logging.getLogger(__name__)
+
class Web_Editor(http.Controller):
#------------------------------------------------------
# convert font into picture
@@ -296,40 +294,57 @@ class Web_Editor(http.Controller):
result['originalSrc'] = record.url
return result
- def _get_view_fields_to_read(self):
- return ['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id']
-
- ## The get_assets_editor_resources route is in charge of transmitting the resources the assets
- ## editor needs to work.
- ## @param key - the xml_id or id of the view the resources are related to
- ## @param get_views - True if the views must be fetched (default to True)
- ## @param get_scss - True if the style must be fetched (default to True)
- ## @param get_js - True if the javascript must be fetched (default to True)
- ## @param bundles - True if the bundles views must be fetched (default to False)
- ## @param bundles_restriction - Names of the bundle in which to look for scss files (if empty, search in all of them)
- ## @param only_user_custom_files - True if only user custom files are needed (default to True)
- ## @returns a dictionary with views info in the views key and style info in the scss key
@http.route("/web_editor/get_assets_editor_resources", type="json", auth="user", website=True)
def get_assets_editor_resources(self, key, get_views=True, get_scss=True, get_js=True, bundles=False, bundles_restriction=[], only_user_custom_files=True):
+ """
+ Transmit the resources the assets editor needs to work.
+
+ Params:
+ key (str): the key of the view the resources are related to
+
+ get_views (bool, default=True):
+ True if the views must be fetched
+
+ get_scss (bool, default=True):
+ True if the style must be fetched
+
+ get_js (bool, default=True):
+ True if the javascript must be fetched
+
+ bundles (bool, default=False):
+ True if the bundles views must be fetched
+
+ bundles_restriction (list, default=[]):
+ Names of the bundles in which to look for scss files
+ (if empty, search in all of them)
+
+ only_user_custom_files (bool, default=True):
+ True if only user custom files must be fetched
+
+ Returns:
+ dict: views, scss, js
+ """
# Related views must be fetched if the user wants the views and/or the style
views = request.env["ir.ui.view"].get_related_views(key, bundles=bundles)
- views = views.read(self._get_view_fields_to_read())
+ views = views.read(request.env['web_editor.assets'].get_view_fields_to_read())
- js_files_data_by_bundle = []
scss_files_data_by_bundle = []
+ js_files_data_by_bundle = []
- if get_js:
- js_files_data_by_bundle = self.load_resources('js', views, bundles_restriction, only_user_custom_files)
if get_scss:
- scss_files_data_by_bundle = self.load_resources('scss', views, bundles_restriction, only_user_custom_files)
+ scss_files_data_by_bundle = self._load_resources('scss', views, bundles_restriction, only_user_custom_files)
+ if get_js:
+ js_files_data_by_bundle = self._load_resources('js', views, bundles_restriction, only_user_custom_files)
- return dict(
- views=get_views and views or [],
- scss=get_scss and scss_files_data_by_bundle or [],
- js=get_js and js_files_data_by_bundle or [],
- )
+ return {
+ 'views': get_views and views or [],
+ 'scss': get_scss and scss_files_data_by_bundle or [],
+ 'js': get_js and js_files_data_by_bundle or [],
+ }
+
+ def _load_resources(self, file_type, views, bundles_restriction, only_user_custom_files):
+ AssetsUtils = request.env['web_editor.assets']
- def load_resources(self, file_type, views, bundles_restriction, only_user_custom_files):
files_data_by_bundle = []
resources_type_info = {'t_call_assets_attribute': 't-js', 'mimetype': 'text/javascript'}
if file_type == 'scss':
@@ -339,10 +354,6 @@ class Web_Editor(http.Controller):
# This will used to exclude library scss files from the result
excluded_url_matcher = re.compile("^(.+/lib/.+)|(.+import_bootstrap.+\.scss)$")
- # Load already customized files attachments
- custom_url = self._make_custom_scss_or_js_file_url("%%.%%", "%%")
- custom_attachments = self.get_custom_attachment(custom_url, op='=like')
-
# First check the t-call-assets used in the related views
url_infos = dict()
for v in views:
@@ -363,7 +374,7 @@ class Web_Editor(http.Controller):
continue
# Check if the file is customized and get bundle/path info
- file_data = self._match_scss_or_js_file_url(url)
+ file_data = AssetsUtils.get_asset_info(url)
if not file_data:
continue
@@ -375,13 +386,19 @@ class Web_Editor(http.Controller):
or file_type == 'scss' and not only_user_custom_files:
files_data.append(url)
- # File data is returned sorted by bundle, with the bundles names and xmlids
+ # scss data is returned sorted by bundle, with the bundles
+ # names and xmlids
if len(files_data):
- files_data_by_bundle.append([dict(xmlid=asset_name, name=request.env.ref(asset_name).name), files_data])
+ files_data_by_bundle.append([
+ {'xmlid': asset_name, 'name': request.env.ref(asset_name).name},
+ files_data
+ ])
# Filter bundles/files:
- # - A file which appears in multiple bundles only appears in the first one (the first in the DOM)
- # - Only keep bundles with files which appears in the asked bundles and only keep those files
+ # - A file which appears in multiple bundles only appears in the
+ # first one (the first in the DOM)
+ # - Only keep bundles with files which appears in the asked bundles
+ # and only keep those files
for i in range(0, len(files_data_by_bundle)):
bundle_1 = files_data_by_bundle[i]
for j in range(0, len(files_data_by_bundle)):
@@ -393,7 +410,8 @@ class Web_Editor(http.Controller):
bundle_1 = files_data_by_bundle[i]
for j in range(i + 1, len(files_data_by_bundle)):
bundle_2 = files_data_by_bundle[j]
- # In every bundle, keep only the files which were not found in previous bundles
+ # In every bundle, keep only the files which were not found
+ # in previous bundles
bundle_2[1] = [item_2 for item_2 in bundle_2[1] if item_2 not in bundle_1[1]]
# Only keep bundles which still have files and that were requested
@@ -403,146 +421,57 @@ class Web_Editor(http.Controller):
]
# Fetch the arch of each kept file, in each bundle
+ urls = []
+ for bundle_data in files_data_by_bundle:
+ urls += bundle_data[1]
+ custom_attachments = AssetsUtils.get_all_custom_attachments(urls)
+
for bundle_data in files_data_by_bundle:
for i in range(0, len(bundle_data[1])):
url = bundle_data[1][i]
url_info = url_infos[url]
- content = None
- if url_info["customized"]:
- # If the file is already customized, the content is found in the corresponding attachment
- content = base64.b64decode(custom_attachments.filtered(lambda a: a.url == url).datas)
- else:
- # If the file is not yet customized, the content is found by reading the local scss file
- module = url_info["module"]
- module_path = get_module_path(module)
- module_resource_path = get_resource_path(module, url_info["resource_path"])
- if module_path and module_resource_path:
- module_path = os.path.join(os.path.normpath(module_path), '') # join ensures the path ends with '/'
- module_resource_path = os.path.normpath(module_resource_path)
- if module_resource_path.startswith(module_path):
- with open(module_resource_path, "rb") as f:
- content = f.read()
+ content = AssetsUtils.get_asset_content(url, url_info, custom_attachments)
+
+ bundle_data[1][i] = {
+ 'url': "/%s/%s" % (url_info["module"], url_info["resource_path"]),
+ 'arch': content,
+ 'customized': url_info["customized"],
+ }
- bundle_data[1][i] = dict(
- url="/%s/%s" % (url_info["module"], url_info["resource_path"]),
- arch=content,
- customized=url_info["customized"],
- )
return files_data_by_bundle
- def save_scss_or_js_view_hook(self):
- return {}
+ @http.route("/web_editor/save_asset", type="json", auth="user", website=True)
+ def save_asset(self, url, bundle_xmlid, content, file_type):
+ """
+ Save a given modification of a scss/js file.
- def save_scss_or_js_attachment_hook(self):
- return {}
+ Params:
+ url (str):
+ the original url of the scss/js file which has to be modified
- ## The save_scss_or_js route is in charge of saving a given modification of a scss/js file.
- ## @param url - the original url of the scss/js file which has to be modified
- ## @param bundle_xmlid - the xmlid of the bundle in which the scss/js file addition can be found
- ## @param content - the new content of the scss/js file
- ## @param file_type - scss or js
- @http.route("/web_editor/save_scss_or_js", type="json", auth="user", website=True)
- def save_scss_or_js(self, url, bundle_xmlid, content, file_type):
- IrAttachment = request.env["ir.attachment"]
+ bundle_xmlid (str):
+ the xmlid of the bundle in which the scss/js file addition can
+ be found
- custom_url = self._make_custom_scss_or_js_file_url(url, bundle_xmlid)
+ content (str): the new content of the scss/js file
- # Check if the file to save had already been modified
- custom_attachment = self.get_custom_attachment(custom_url)
- datas = base64.b64encode((content or "\n").encode("utf-8"))
- if custom_attachment:
- # If it was already modified, simply override the corresponding attachment content
- custom_attachment.write({"datas": datas})
- else:
- # If not, create a new attachment to copy the original scss/js file content, with its modifications
- new_attach = {
- 'name': custom_url,
- 'type': "binary",
- 'mimetype': (file_type == 'js' and 'text/javascript' or 'text/scss'),
- 'datas': datas,
- 'datas_fname': url.split("/")[-1],
- 'url': custom_url,
- }
- new_attach.update(self.save_scss_or_js_attachment_hook())
- IrAttachment.create(new_attach)
+ file_type (str): 'scss' or 'js'
+ """
+ request.env['web_editor.assets'].save_asset(url, bundle_xmlid, content, file_type)
- # Create a view to extend the template which adds the original file to link the new modified version instead
- IrUiView = request.env["ir.ui.view"]
- file_type_info = {
- 'tag': 'link' if file_type == 'scss' else 'script',
- 'attribute': 'href' if file_type == 'scss' else 'src',
- }
+ @http.route("/web_editor/reset_asset", type="json", auth="user", website=True)
+ def reset_asset(self, url, bundle_xmlid):
+ """
+ The reset_asset route is in charge of reverting all the changes that
+ were done to a scss/js file.
- def views_linking_url(view):
- """
- Returns whether the view arch has some html link tag linked to the url.
+ Params:
+ url (str):
+ the original URL of the scss/js file to reset
- (note: searching for the URL string is not enough as it could appear in a comment or an xpath expression.)
- """
- tree = etree.XML(view.arch)
- return bool(tree.xpath("//%%(tag)s[@%%(attribute)s='%(url)s']" % {
- 'url': url,
- } % file_type_info))
-
- view_to_xpath = IrUiView.get_related_views(bundle_xmlid, bundles=True).filtered(views_linking_url)
-
- new_view = {
- 'name': custom_url,
- 'key': 'web_editor.%s_%s' % (file_type, str(uuid.uuid4())[:6]),
- 'mode': "extension",
- 'inherit_id': view_to_xpath.id,
- 'arch': """
-
-
- %(new_url)s
-
-
- """ % {
- 'inherit_xml_id': view_to_xpath.xml_id,
- 'name': custom_url,
- 'url_to_replace': url,
- 'new_url': custom_url,
- } % file_type_info
- }
- new_view.update(self.save_scss_or_js_view_hook())
- IrUiView.create(new_view)
-
- request.env["ir.qweb"].clear_caches()
-
- ## The reset_scss_or_js route is in charge of reverting all the changes that were done to a scss/js file.
- ## @param url - the original URL of the scss file to reset
- ## @param bundle_xmlid - the xmlid of the bundle in which the scss file addition can be found
- @http.route("/web_editor/reset_scss_or_js", type="json", auth="user", website=True)
- def reset_scss_or_js(self, url, bundle_xmlid):
- custom_url = self._make_custom_scss_or_js_file_url(url, bundle_xmlid)
-
- # Simply delete the attachement which contains the modified scss/js file and the xpath view which links it
- self.get_custom_attachment(custom_url).unlink()
- self.get_custom_view(custom_url).unlink()
-
- def get_custom_attachment(self, custom_url, op='='):
- assert op in ('=like', '='), 'Invalid operator'
- IrAttachment = request.env["ir.attachment"]
- return IrAttachment.search([("url", op, custom_url)])
-
- def get_custom_view(self, custom_url, op='='):
- assert op in ('=like', '='), 'Invalid operator'
- IrUiView = request.env["ir.ui.view"]
- return IrUiView.search([("name", op, custom_url)])
-
- def _make_custom_scss_or_js_file_url(self, url, bundle):
- parts = url.rsplit(".", 1)
- return "%s.custom.%s.%s" % (parts[0], bundle, parts[1])
-
- _match_file_url_regex = re.compile("^/(\w+)/(.+?)(\.custom\.(.+))?\.(\w+)$")
- def _match_scss_or_js_file_url(self, url):
- m = self._match_file_url_regex.match(url)
- if not m:
- return False
- return {
- 'module': m.group(1),
- 'resource_path': "%s.%s" % (m.group(2), m.group(5)),
- 'customized': bool(m.group(3)),
- 'bundle': m.group(4) or False
- }
+ bundle_xmlid (str):
+ the xmlid of the bundle in which the scss/js file addition can
+ be found
+ """
+ request.env['web_editor.assets'].reset_asset(url, bundle_xmlid)
diff --git a/addons/web_editor/models/__init__.py b/addons/web_editor/models/__init__.py
index b895b36ca54..e9a790f7e61 100644
--- a/addons/web_editor/models/__init__.py
+++ b/addons/web_editor/models/__init__.py
@@ -7,4 +7,6 @@ from . import ir_ui_view
from . import ir_http
from . import ir_translation
+from . import assets
+
from . import test_models
diff --git a/addons/web_editor/models/assets.py b/addons/web_editor/models/assets.py
new file mode 100644
index 00000000000..9406fc495b2
--- /dev/null
+++ b/addons/web_editor/models/assets.py
@@ -0,0 +1,279 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+import os
+import re
+import uuid
+
+from lxml import etree
+
+from odoo import models
+from odoo.modules.module import get_resource_path, get_module_path
+
+_match_asset_file_url_regex = re.compile("^/(\w+)/(.+?)(\.custom\.(.+))?\.(\w+)$")
+
+
+class Assets(models.AbstractModel):
+ _name = 'web_editor.assets'
+ _description = 'Assets Utils'
+
+ def get_view_fields_to_read(self):
+ return ['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id']
+
+ def get_all_custom_attachments(self, urls):
+ """
+ Fetch all the ir.attachment records related to given URLs.
+
+ Params:
+ urls (str[]): list of urls
+
+ Returns:
+ ir.attachment(): attachment records related to the given URLs.
+ """
+ return self._get_custom_attachment(urls, op='in')
+
+ def get_asset_content(self, url, url_info=None, custom_attachments=None):
+ """
+ Fetch the content of an asset (scss / js) file. That content is either
+ the one of the related file on the disk or the one of the corresponding
+ custom ir.attachment record.
+
+ Params:
+ url (str): the URL of the asset (scss / js) file/ir.attachment
+
+ url_info (dict, optional):
+ the related url info (see get_asset_info) (allows to optimize
+ some code which already have the info and do not want this
+ function to re-get it)
+
+ custom_attachments (ir.attachment(), optional):
+ the related custom ir.attachment records the function might need
+ to search into (allows to optimize some code which already have
+ that info and do not want this function to re-get it)
+
+ Returns:
+ utf-8 encoded content of the asset (scss / js)
+ """
+ if url_info is None:
+ url_info = self.get_asset_info(url)
+
+ if url_info["customized"]:
+ # If the file is already customized, the content is found in the
+ # corresponding attachment
+ attachment = None
+ if custom_attachments is None:
+ attachment = self._get_custom_attachment(url)
+ else:
+ attachment = custom_attachments.filtered(lambda r: r.url == url)
+ return attachment and base64.b64decode(attachment.datas) or False
+
+ # If the file is not yet customized, the content is found by reading
+ # the local scss file
+ module = url_info["module"]
+ module_path = get_module_path(module)
+ module_resource_path = get_resource_path(module, url_info["resource_path"])
+ if module_path and module_resource_path:
+ module_path = os.path.join(os.path.normpath(module_path), '') # join ensures the path ends with '/'
+ module_resource_path = os.path.normpath(module_resource_path)
+ if module_resource_path.startswith(module_path):
+ with open(module_resource_path, "rb") as f:
+ return f.read()
+
+ def get_asset_info(self, url):
+ """
+ Return information about an asset (scss / js) file/ir.attachment just by
+ looking at its URL.
+
+ Params:
+ url (str): the url of the asset (scss / js) file/ir.attachment
+
+ Returns:
+ dict:
+ module (str): the original asset's related app
+
+ resource_path (str):
+ the relative path to the original asset from the related app
+
+ customized (bool): whether the asset is a customized one or not
+
+ bundle (str):
+ the name of the bundle the asset customizes (False if this
+ is not a customized asset)
+ """
+ m = _match_asset_file_url_regex.match(url)
+ if not m:
+ return False
+ return {
+ 'module': m.group(1),
+ 'resource_path': "%s.%s" % (m.group(2), m.group(5)),
+ 'customized': bool(m.group(3)),
+ 'bundle': m.group(4) or False
+ }
+
+ def make_custom_asset_file_url(self, url, bundle_xmlid):
+ """
+ Return the customized version of an asset URL, that is the URL the asset
+ would have if it was customized.
+
+ Params:
+ url (str): the original asset's url
+ bundle_xmlid (str): the name of the bundle the asset would customize
+
+ Returns:
+ str: the URL the given asset would have if it was customized in the
+ given bundle
+ """
+ parts = url.rsplit(".", 1)
+ return "%s.custom.%s.%s" % (parts[0], bundle_xmlid, parts[1])
+
+ def reset_asset(self, url, bundle_xmlid):
+ """
+ Delete the potential customizations made to a given (original) asset.
+
+ Params:
+ url (str): the URL of the original asset (scss / js) file
+
+ bundle_xmlid (str):
+ the name of the bundle in which the customizations to delete
+ were made
+ """
+ custom_url = self.make_custom_asset_file_url(url, bundle_xmlid)
+
+ # Simply delete the attachement which contains the modified scss/js file
+ # and the xpath view which links it
+ self._get_custom_attachment(custom_url).unlink()
+ self._get_custom_view(custom_url).unlink()
+
+ def save_asset(self, url, bundle_xmlid, content, file_type):
+ """
+ Customize the content of a given asset (scss / js).
+
+ Params:
+ url (src):
+ the URL of the original asset to customize (whether or not the
+ asset was already customized)
+
+ bundle_xmlid (src):
+ the name of the bundle in which the customizations will take
+ effect
+
+ content (src): the new content of the asset (scss / js)
+
+ file_type (src):
+ either 'scss' or 'js' according to the file being customized
+ """
+ custom_url = self.make_custom_asset_file_url(url, bundle_xmlid)
+ datas = base64.b64encode((content or "\n").encode("utf-8"))
+
+ # Check if the file to save had already been modified
+ custom_attachment = self._get_custom_attachment(custom_url)
+ if custom_attachment:
+ # If it was already modified, simply override the corresponding
+ # attachment content
+ custom_attachment.write({"datas": datas})
+ else:
+ # If not, create a new attachment to copy the original scss/js file
+ # content, with its modifications
+ new_attach = {
+ 'name': custom_url,
+ 'type': "binary",
+ 'mimetype': (file_type == 'js' and 'text/javascript' or 'text/scss'),
+ 'datas': datas,
+ 'datas_fname': url.split("/")[-1],
+ 'url': custom_url,
+ }
+ new_attach.update(self._save_asset_attachment_hook())
+ self.env["ir.attachment"].create(new_attach)
+
+ # Create a view to extend the template which adds the original file
+ # to link the new modified version instead
+ file_type_info = {
+ 'tag': 'link' if file_type == 'scss' else 'script',
+ 'attribute': 'href' if file_type == 'scss' else 'src',
+ }
+
+ def views_linking_url(view):
+ """
+ Returns whether the view arch has some html tag linked to
+ the url. (note: searching for the URL string is not enough as it
+ could appear in a comment or an xpath expression.)
+ """
+ tree = etree.XML(view.arch)
+ return bool(tree.xpath("//%%(tag)s[@%%(attribute)s='%(url)s']" % {
+ 'url': url,
+ } % file_type_info))
+
+ IrUiView = self.env["ir.ui.view"]
+ view_to_xpath = IrUiView.get_related_views(bundle_xmlid, bundles=True).filtered(views_linking_url)
+ new_view = {
+ 'name': custom_url,
+ 'key': 'web_editor.%s_%s' % (file_type, str(uuid.uuid4())[:6]),
+ 'mode': "extension",
+ 'inherit_id': view_to_xpath.id,
+ 'arch': """
+
+
+ %(new_url)s
+
+
+ """ % {
+ 'inherit_xml_id': view_to_xpath.xml_id,
+ 'name': custom_url,
+ 'url_to_replace': url,
+ 'new_url': custom_url,
+ } % file_type_info
+ }
+ new_view.update(self._save_asset_view_hook())
+ IrUiView.create(new_view)
+
+ self.env["ir.qweb"].clear_caches()
+
+ def _get_custom_attachment(self, custom_url, op='='):
+ """
+ Fetch the ir.attachment record related to the given customized asset.
+
+ Params:
+ custom_url (str): the URL of the customized asset
+ op (str, default: '='): the operator to use to search the records
+
+ Returns:
+ ir.attachment()
+ """
+ assert op in ('in', '='), 'Invalid operator'
+ return self.env["ir.attachment"].search([("url", op, custom_url)])
+
+ def _get_custom_view(self, custom_url, op='='):
+ """
+ Fetch the ir.ui.view record related to the given customized asset (the
+ inheriting view which replace the original asset by the customized one).
+
+ Params:
+ custom_url (str): the URL of the customized asset
+ op (str, default: '='): the operator to use to search the records
+
+ Returns:
+ ir.ui.view()
+ """
+ assert op in ('='), 'Invalid operator'
+ return self.env["ir.ui.view"].search([("name", op, custom_url)])
+
+ def _save_asset_attachment_hook(self):
+ """
+ Returns the additional values to use to write the DB on customized
+ attachment creation.
+
+ Returns:
+ dict
+ """
+ return {}
+
+ def _save_asset_view_hook(self):
+ """
+ Returns the additional values to use to write the DB on customized
+ asset's related view creation.
+
+ Returns:
+ dict
+ """
+ return {}
diff --git a/addons/web_editor/static/src/js/common/ace.js b/addons/web_editor/static/src/js/common/ace.js
index f231bb0aac6..f242129c4a8 100644
--- a/addons/web_editor/static/src/js/common/ace.js
+++ b/addons/web_editor/static/src/js/common/ace.js
@@ -535,7 +535,7 @@ var ViewEditor = Widget.extend({
} else {
var resource = type === 'scss' ? this.scss[resID] : this.js[resID];
return this._rpc({
- route: '/web_editor/reset_scss_or_js',
+ route: '/web_editor/reset_asset',
params: {
url: resID,
bundle_xmlid: resource.bundle_xmlid,
@@ -560,7 +560,7 @@ var ViewEditor = Widget.extend({
var bundleXmlID = sessionIdEndsWithJS ? this.js[session.id].bundle_xmlid : this.scss[session.id].bundle_xmlid;
var fileType = sessionIdEndsWithJS ? 'js' : 'scss';
self._rpc({
- route: '/web_editor/save_scss_or_js',
+ route: '/web_editor/save_asset',
params: {
url: session.id,
bundle_xmlid: bundleXmlID,
diff --git a/addons/website/controllers/__init__.py b/addons/website/controllers/__init__.py
index 9b1d03120de..5ee7aaab8f2 100644
--- a/addons/website/controllers/__init__.py
+++ b/addons/website/controllers/__init__.py
@@ -3,4 +3,3 @@
from . import backend
from . import main
-from . import web_editor
diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py
index 3852fefe740..a2d1d332f80 100644
--- a/addons/website/controllers/main.py
+++ b/addons/website/controllers/main.py
@@ -4,6 +4,7 @@ import base64
import datetime
import json
import os
+import re
import logging
import requests
import werkzeug.utils
@@ -331,45 +332,27 @@ class Website(Home):
# Themes
# ------------------------------------------------------
- def get_view_ids(self, xml_ids):
- ids = []
+ def _get_customize_views(self, xml_ids):
View = request.env["ir.ui.view"].with_context(active_test=False)
- for xml_id in xml_ids:
- if "." in xml_id:
- # Get website-specific view if possible
- record_id = View.search([
- ("website_id", "=", request.website.id),
- ("key", "=", xml_id),
- ], limit=1).id or request.env.ref(xml_id).id
- else:
- record_id = int(xml_id)
- ids.append(record_id)
- return ids
+ if not xml_ids:
+ return View
+ domain = [("key", "in", xml_ids)] + request.website.website_domain()
+ return View.search(domain).filter_duplicate()
@http.route(['/website/theme_customize_get'], type='json', auth="public", website=True)
def theme_customize_get(self, xml_ids):
- enable = []
- disable = []
- ids = self.get_view_ids(xml_ids)
- for view in request.env['ir.ui.view'].browse(ids):
- if view.active:
- enable.append(view.key)
- else:
- disable.append(view.key)
- return [enable, disable]
+ views = self._get_customize_views(xml_ids)
+ return {
+ 'enabled': views.filtered('active').mapped('key'),
+ 'names': {view.key: view.name for view in views},
+ }
@http.route(['/website/theme_customize'], type='json', auth="public", website=True)
def theme_customize(self, enable=None, disable=None, get_bundle=False):
""" enable or Disable lists of ``xml_id`` of the inherit templates """
- def set_active(xml_ids, active):
- if xml_ids:
- real_ids = self.get_view_ids(xml_ids)
- request.env['ir.ui.view'].browse(real_ids).write({'active': active})
- if disable:
- set_active(disable, False)
- if enable:
- set_active(enable, True)
+ self._get_customize_views(disable).write({'active': False})
+ self._get_customize_views(enable).write({'active': True})
if get_bundle:
context = dict(request.context)
@@ -386,6 +369,42 @@ class Website(Home):
self.theme_customize(enable and enable.split(",") or [], disable and disable.split(",") or [])
return request.redirect(href + ("&theme=true" if "#" in href else "#theme=true") + ("&tab=" + tab))
+ @http.route(['/website/make_scss_custo'], type='json', auth='user', website=True)
+ def make_scss_custo(self, url, values):
+ """
+ Makes a scss customization of the given file. That file must
+ contain a scss map including a line comment containing the word 'hook',
+ to indicate the location where to write the new key,value pairs.
+
+ Params:
+ url (str):
+ the URL of the scss file to customize (supposed to be a variable
+ file which will appear in the assets_common bundle)
+
+ values (dict):
+ key,value mapping to integrate in the file's map (containing the
+ word hook). If a key is already in the file's map, its value is
+ overridden.
+ """
+ AssetsUtils = request.env['web_editor.assets']
+
+ custom_url = AssetsUtils.make_custom_asset_file_url(url, 'web.assets_common')
+ updatedFileContent = AssetsUtils.get_asset_content(custom_url) or AssetsUtils.get_asset_content(url)
+ updatedFileContent = updatedFileContent.decode('utf-8')
+ for name, value in values.items():
+ pattern = "'%s': %%s,\n" % name
+ regex = re.compile(pattern % ".+")
+ replacement = pattern % value
+ if regex.search(updatedFileContent):
+ updatedFileContent = re.sub(regex, replacement, updatedFileContent)
+ else:
+ updatedFileContent = re.sub(r'( *)(.*hook.*)', r'\1%s\1\2' % replacement, updatedFileContent)
+
+ # Bundle is 'assets_common' as this route is only meant to update
+ # variables scss files
+ AssetsUtils.save_asset(url, 'web.assets_common', updatedFileContent, 'scss')
+ return True
+
@http.route(['/website/multi_render'], type='json', auth="public", website=True)
def multi_render(self, ids_or_xml_ids, values=None):
View = request.env['ir.ui.view']
diff --git a/addons/website/controllers/web_editor.py b/addons/website/controllers/web_editor.py
deleted file mode 100644
index 92aa9bb1d8a..00000000000
--- a/addons/website/controllers/web_editor.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo.addons.web_editor.controllers.main import Web_Editor
-from odoo.http import request
-
-
-class Web_Editor(Web_Editor):
-
- def _get_view_fields_to_read(self):
- res = super(Web_Editor, self)._get_view_fields_to_read()
- res.append('website_id')
- return res
-
- def save_scss_or_js_view_hook(self):
- res = super(Web_Editor, self).save_scss_or_js_view_hook()
-
- website = request.env['website'].get_current_website()
- if website:
- res['website_id'] = website.id
- return res
-
- def save_scss_or_js_attachment_hook(self):
- res = super(Web_Editor, self).save_scss_or_js_attachment_hook()
-
- website = request.env['website'].get_current_website()
- if website:
- res['website_id'] = website.id
- return res
-
- def get_custom_attachment(self, custom_url, op='='):
- website = request.env['website'].get_current_website()
- res = super(Web_Editor, self).get_custom_attachment(custom_url, op=op)
- return res.with_context(website_id=website.id).filtered(lambda x: not x.website_id or x.website_id == website)
-
- def get_custom_view(self, custom_url, op='='):
- website = request.env['website'].get_current_website()
- res = super(Web_Editor, self).get_custom_view(custom_url, op=op)
- return res.with_context(website_id=website.id).filter_duplicate()
diff --git a/addons/website/models/__init__.py b/addons/website/models/__init__.py
index fd2679198ed..173075aafbf 100644
--- a/addons/website/models/__init__.py
+++ b/addons/website/models/__init__.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import assets
from . import ir_actions
from . import ir_attachment
from . import ir_http
diff --git a/addons/website/models/assets.py b/addons/website/models/assets.py
new file mode 100644
index 00000000000..0d2be3d2b91
--- /dev/null
+++ b/addons/website/models/assets.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class Assets(models.AbstractModel):
+ _inherit = 'web_editor.assets'
+
+ def get_view_fields_to_read(self):
+ res = super(Assets, self).get_view_fields_to_read()
+ res.append('website_id')
+ return res
+
+ def _get_custom_attachment(self, custom_url, op='='):
+ """
+ See web_editor.Assets._get_custom_attachment
+ Extend to only return the attachments related to the current website.
+ """
+ website = self.env['website'].get_current_website()
+ res = super(Assets, self)._get_custom_attachment(custom_url, op=op)
+ return res.with_context(website_id=website.id).filtered(lambda x: not x.website_id or x.website_id == website)
+
+ def _get_custom_view(self, custom_url, op='='):
+ """
+ See web_editor.Assets._get_custom_view
+ Extend to only return the views related to the current website.
+ """
+ website = self.env['website'].get_current_website()
+ res = super(Assets, self)._get_custom_view(custom_url, op=op)
+ return res.with_context(website_id=website.id).filter_duplicate()
+
+ def _save_asset_attachment_hook(self):
+ """
+ See web_editor.Assets._save_asset_attachment_hook
+ Extend to add website ID at attachment creation.
+ """
+ res = super(Assets, self)._save_asset_attachment_hook()
+
+ website = self.env['website'].get_current_website()
+ if website:
+ res['website_id'] = website.id
+ return res
+
+ def _save_asset_view_hook(self):
+ """
+ See web_editor.Assets._save_asset_view_hook
+ Extend to add website ID at view creation.
+ """
+ res = super(Assets, self)._save_asset_view_hook()
+
+ website = self.env['website'].get_current_website()
+ if website:
+ res['website_id'] = website.id
+ return res
diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js
index d64a3891bfd..7e06140f657 100644
--- a/addons/website/static/src/js/content/menu.js
+++ b/addons/website/static/src/js/content/menu.js
@@ -3,6 +3,7 @@ odoo.define('website.content.menu', function (require) {
var dom = require('web.dom');
var sAnimation = require('website.content.snippets.animation');
+var wUtils = require('website.utils');
sAnimation.registry.affixMenu = sAnimation.Class.extend({
selector: 'header.o_affix_enabled',
@@ -23,6 +24,11 @@ sAnimation.registry.affixMenu = sAnimation.Class.extend({
this.$dropdowns = this.$headers.find('.dropdown');
this.$navbarCollapses = this.$headers.find('.navbar-collapse');
+ this._adaptDefaultOffset();
+ wUtils.onceAllImagesLoaded(this.$headerClone).then(function () {
+ self._adaptDefaultOffset();
+ });
+
// Handle events for the collapse menus
_.each(this.$headerClone.find('[data-toggle="collapse"]'), function (el) {
var $source = $(el);
@@ -49,6 +55,24 @@ sAnimation.registry.affixMenu = sAnimation.Class.extend({
this._super.apply(this, arguments);
},
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adaptDefaultOffset: function () {
+ var bottom = this.$target.offset().top + this._getHeaderHeight();
+ this.$headerClone.css('margin-top', Math.min(-200, -bottom) + 'px');
+ },
+ /**
+ * @private
+ */
+ _getHeaderHeight: function () {
+ return this.$headerClone.outerHeight();
+ },
+
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
@@ -92,16 +116,7 @@ sAnimation.registry.autohideMenu = sAnimation.Class.extend({
this.noAutohide = this.$el.closest('.o_no_autohide_menu').length;
if (!this.noAutohide) {
var $navbar = this.$el.closest('.navbar');
- _.each($navbar.find('img'), function (img) {
- if (img.complete) {
- return; // Already loaded
- }
- var def = $.Deferred();
- defs.push(def);
- $(img).one('load', function () {
- def.resolve();
- });
- });
+ defs.push(wUtils.onceAllImagesLoaded($navbar));
// The previous code will make sure we wait for images to be fully
// loaded before initializing the auto more menu. But in some cases,
diff --git a/addons/website/static/src/js/menu/customize.js b/addons/website/static/src/js/menu/customize.js
index fc254626dd2..aced336de2c 100644
--- a/addons/website/static/src/js/menu/customize.js
+++ b/addons/website/static/src/js/menu/customize.js
@@ -81,6 +81,12 @@ var CustomizeMenu = Widget.extend({
},
}).then(function (result) {
var currentGroup = '';
+ if (result.length) {
+ $menu.append($('
', {
+ class: 'dropdown-divider',
+ role: 'separator',
+ }));
+ }
_.each(result, function (item) {
if (currentGroup !== item.inherit_id[1]) {
currentGroup = item.inherit_id[1];
diff --git a/addons/website/static/src/js/utils.js b/addons/website/static/src/js/utils.js
index 9cf511fe160..01f0e7797a4 100644
--- a/addons/website/static/src/js/utils.js
+++ b/addons/website/static/src/js/utils.js
@@ -60,6 +60,24 @@ function autocompleteWithPages(self, $input) {
},
});
}
+
+/**
+ * @param {jQuery} $element
+ */
+function onceAllImagesLoaded($element) {
+ var defs = _.map($element.find('img').addBack('img'), function (img) {
+ if (img.complete) {
+ return; // Already loaded
+ }
+ var def = $.Deferred();
+ $(img).one('load', function () {
+ def.resolve();
+ });
+ return def;
+ });
+ return $.when.apply($, defs);
+}
+
/**
* @deprecated
* @todo create Dialog.prompt instead of this
@@ -168,6 +186,7 @@ function prompt(options, _qweb) {
return {
loadAnchors: loadAnchors,
autocompleteWithPages: autocompleteWithPages,
+ onceAllImagesLoaded: onceAllImagesLoaded,
prompt: prompt,
};
});
diff --git a/addons/website/static/src/js/widgets/theme.js b/addons/website/static/src/js/widgets/theme.js
index 36f59a23762..a5e0731e4b0 100644
--- a/addons/website/static/src/js/widgets/theme.js
+++ b/addons/website/static/src/js/widgets/theme.js
@@ -3,26 +3,118 @@ odoo.define('website.theme', function (require) {
var config = require('web.config');
var core = require('web.core');
-var ColorpickerDialog = require('wysiwyg.widgets.ColorpickerDialog');
var Dialog = require('web.Dialog');
+var Widget = require('web.Widget');
var weWidgets = require('wysiwyg.widgets');
+var ColorpickerDialog = require('wysiwyg.widgets.ColorpickerDialog');
var websiteNavbarData = require('website.navbar');
var _t = core._t;
var templateDef = null;
+var QuickEdit = Widget.extend({
+ xmlDependencies: ['/website/static/src/xml/website.editor.xml'],
+ template: 'website.theme_customize_active_input',
+ events: {
+ 'keydown input': '_onInputKeydown',
+ 'click .btn-primary': '_onSaveClick',
+ 'click .btn-secondary': '_onResetClick',
+ },
+
+ /**
+ * @constructor
+ */
+ init: function (parent, value, unit) {
+ this._super.apply(this, arguments);
+ this.value = value;
+ this.unit = unit;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$input = this.$('input');
+ this.$input.select();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} [value]
+ */
+ _save: function (value) {
+ if (value === undefined) {
+ value = parseFloat(this.$input.val());
+ value = isNaN(value) ? 'null' : (value + this.unit);
+ }
+ this.trigger_up('QuickEdit:save', {
+ value: value,
+ });
+ this.destroy();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInputKeydown: function (ev) {
+ var inputValue = this.$input.val();
+ var value = 0;
+ if (inputValue !== '') {
+ value = parseFloat(this.$input.val());
+ if (isNaN(value)) {
+ return;
+ }
+ }
+ switch (ev.which) {
+ case $.ui.keyCode.UP:
+ this.$input.val(value + 1);
+ break;
+ case $.ui.keyCode.DOWN:
+ this.$input.val(value - 1);
+ break;
+ case $.ui.keyCode.ENTER:
+ // Do not listen to change events, we want the user to be able
+ // to confirm in all cases.
+ this._save();
+ break;
+ }
+ },
+ /**
+ * @private
+ */
+ _onSaveClick: function () {
+ this._save();
+ },
+ /**
+ * @private
+ */
+ _onResetClick: function () {
+ this._save('null');
+ },
+});
+
var ThemeCustomizeDialog = Dialog.extend({
xmlDependencies: (Dialog.prototype.xmlDependencies || [])
.concat(['/website/static/src/xml/website.editor.xml']),
template: 'website.theme_customize',
events: {
- 'change [data-xmlid], [data-enable], [data-disable]': '_onChange',
- 'click .checked [data-xmlid], .checked [data-enable], .checked [data-disable]': '_onChange',
- 'click .o_theme_customize_color': '_onColorClick',
+ 'change .o_theme_customize_option_input': '_onChange',
+ 'click .checked .o_theme_customize_option_input[type="radio"]': '_onChange',
},
+ CUSTOM_BODY_IMAGE_XML_ID: 'option_custom_body_image',
+
/**
* @constructor
*/
@@ -58,29 +150,53 @@ var ThemeCustomizeDialog = Dialog.extend({
*/
start: function () {
var self = this;
- this._generateDialogHTML();
+
+ this.PX_BY_REM = parseFloat($(document.documentElement).css('font-size'));
+
this.$modal.addClass('o_theme_customize_modal');
+ this.style = window.getComputedStyle(document.documentElement);
+
+ var $tabs;
+ var loadDef = this._loadViews().then(function (data) {
+ self._generateDialogHTML(data);
+ $tabs = self.$('[data-toggle="tab"]');
+
+ // Hide the tab navigation if only one tab
+ if ($tabs.length <= 1) {
+ $tabs.closest('.nav').addClass('d-none');
+ }
+ });
+
// Enable the first option tab or the given default tab
- var $tabs = this.$('[data-toggle="tab"]');
this.opened().then(function () {
$tabs.eq(self.defaultTab).tab('show');
- var $colorPreview = self.$('.o_theme_customize_color_previews:visible');
- var $primary = $colorPreview.find('.o_theme_customize_color[data-color="primary"]');
- var $alpha = $colorPreview.find('.o_theme_customize_color[data-color="alpha"]');
- var $secondary = $colorPreview.find('.o_theme_customize_color[data-color="secondary"]');
- var $beta = $colorPreview.find('.o_theme_customize_color[data-color="beta"]');
- var sameAlphaPrimary = $primary.find('.o_color_preview').css('background-color') === $alpha.find('.o_color_preview').css('background-color');
- var sameBetaSecondary = $secondary.find('.o_color_preview').css('background-color') === $beta.find('.o_color_preview').css('background-color');
+ // Hack to hide primary/secondary if they are equal to alpha/beta
+ // (this is the case with default values but not in some themes).
+ var $primary = self.$('.o_theme_customize_color[data-color="primary"]');
+ var $alpha = self.$('.o_theme_customize_color[data-color="alpha"]');
+ var $secondary = self.$('.o_theme_customize_color[data-color="secondary"]');
+ var $beta = self.$('.o_theme_customize_color[data-color="beta"]');
+
+ var sameAlphaPrimary = $primary.css('background-color') === $alpha.css('background-color');
+ var sameBetaSecondary = $secondary.css('background-color') === $beta.css('background-color');
+
if (!sameAlphaPrimary) {
- $alpha.find('.o_color_name').text(_t("Extra Color"));
- $primary.removeClass('d-none').addClass('d-flex');
+ $alpha.prev().text(_t("Extra Color"));
}
if (!sameBetaSecondary) {
- $beta.find('.o_color_name').text(_t("Extra Color"));
- $secondary.removeClass('d-none').addClass('d-flex');
+ $beta.prev().text(_t("Extra Color"));
}
+
+ $primary = $primary.closest('.o_theme_customize_option');
+ $alpha = $alpha.closest('.o_theme_customize_option');
+ $secondary = $secondary.closest('.o_theme_customize_option');
+ $beta = $beta.closest('.o_theme_customize_option');
+
+ $primary.toggleClass('d-none', sameAlphaPrimary);
+ $secondary.toggleClass('d-none', sameBetaSecondary);
+
if (!sameAlphaPrimary && sameBetaSecondary) {
$beta.insertBefore($alpha);
} else if (sameAlphaPrimary && !sameBetaSecondary) {
@@ -88,17 +204,7 @@ var ThemeCustomizeDialog = Dialog.extend({
}
});
- // Hide the tab navigation if only one tab
- if ($tabs.length <= 1) {
- $tabs.closest('.nav').addClass('d-none');
- }
-
- this.$inputs = this.$('[data-xmlid], [data-enable], [data-disable]');
-
- return $.when(
- this._super.apply(this, arguments),
- this._loadViews()
- );
+ return $.when(this._super.apply(this, arguments), loadDef);
},
//--------------------------------------------------------------------------
@@ -108,7 +214,47 @@ var ThemeCustomizeDialog = Dialog.extend({
/**
* @private
*/
- _generateDialogHTML: function () {
+ _chooseBodyCustomImage: function () {
+ var self = this;
+ var def = $.Deferred();
+ var $image = $('');
+ var editor = new weWidgets.MediaDialog(this, {
+ onlyImages: true,
+ firstFilters: ['background'],
+ }, $image[0]);
+
+ editor.on('save', this, function (media) { // TODO use scss customization instead (like for user colors)
+ var src = $(media).attr('src');
+ self._rpc({
+ model: 'ir.model.data',
+ method: 'get_object_reference',
+ args: ['website', this.CUSTOM_BODY_IMAGE_XML_ID],
+ }).then(function (data) {
+ return self._rpc({
+ model: 'ir.ui.view',
+ method: 'save',
+ args: [
+ data[1],
+ '#wrapwrap { background-image: url("' + src + '"); }',
+ '//style',
+ ],
+ });
+ }).always(def.resolve.bind(def));
+ });
+ editor.on('cancel', this, function () {
+ def.resolve();
+ });
+
+ editor.open();
+
+ return def;
+ },
+ /**
+ * @private
+ * @param {Object} data - @see this._loadViews
+ */
+ _generateDialogHTML: function (data) {
+ var self = this;
var $contents = this.$el.children('content');
if ($contents.length === 0) {
return;
@@ -148,8 +294,21 @@ var ThemeCustomizeDialog = Dialog.extend({
this.$('[title]').tooltip();
+ this.$inputs = self.$('.o_theme_customize_option_input');
+ // Enable data-xmlid="" inputs if none of their neighbors were enabled
+ _.each(this.$inputs.filter('[data-xmlid=""]'), function (input) {
+ var $input = $(input);
+ var $neighbors = self.$inputs.filter('[name="' + $input.attr('name') + '"]').not($input);
+ if ($neighbors.length && !$neighbors.filter(':checked').length) {
+ $input.prop('checked', true);
+ }
+ });
+ this._setActive();
+ this._updateValues();
+
function _processItems($items, $container) {
var optionsName = _.uniqueId('option-');
+ var alone = ($items.length === 1);
_.each($items, function (item) {
var $item = $(item);
@@ -159,37 +318,47 @@ var ThemeCustomizeDialog = Dialog.extend({
case 'OPT':
var widgetName = $item.data('widget');
- // Build the options template
- var $option = $(core.qweb.render('website.theme_customize_modal_option', {
- name: optionsName,
- id: $item.attr('id') || _.uniqueId('o_theme_customize_input_id_'),
+ var xmlid = $item.data('xmlid');
- string: $item.attr('string'),
+ var renderingOptions = _.extend({
+ string: $item.attr('string') || data.names[xmlid.split(',')[0].trim()],
icon: $item.data('icon'),
font: $item.data('font'),
+ }, $item.data());
- xmlid: $item.data('xmlid'),
- enable: $item.data('enable'),
- disable: $item.data('disable'),
- reload: $item.data('reload'),
-
+ // Build the options template
+ var $option = $(core.qweb.render('website.theme_customize_modal_option', _.extend({
+ alone: alone,
+ name: xmlid === undefined ? _.uniqueId('option-') : optionsName,
+ id: $item.attr('id') || _.uniqueId('o_theme_customize_input_id_'),
+ checked: xmlid === undefined || xmlid && (!_.difference(self._getXMLIDs($item), data.enabled).length),
widget: widgetName,
- }));
+ }, renderingOptions)));
+ $option.find('input')
+ .addClass('o_theme_customize_option_input')
+ .attr({
+ 'data-xmlid': xmlid,
+ 'data-enable': $item.data('enable'),
+ 'data-disable': $item.data('disable'),
+ 'data-reload': $item.data('reload'),
+ });
if (widgetName) {
- var $widget = $(core.qweb.render('website.theme_customize_' + widgetName));
- $option.append($widget);
+ var $widget = $(core.qweb.render('website.theme_customize_widget_' + widgetName, renderingOptions));
+ $option.find('label').append($widget);
}
+ var $final;
if ($container.hasClass('form-row')) {
- $col = $('', {
+ $final = $('', {
class: _.str.sprintf('col-%s', $item.data('col') || 6),
});
- $col.append($option);
- $container.append($col);
+ $final.append($option);
} else {
- $container.append($option);
+ $final = $option;
}
+ $final.attr('data-depends', $item.data('depends'));
+ $container.append($final);
break;
case 'LIST':
@@ -201,6 +370,10 @@ var ThemeCustomizeDialog = Dialog.extend({
$container.append($col);
_processItems($item.children(), $listContainer);
break;
+
+ default:
+ _processItems($item.children(), $container);
+ break;
}
});
}
@@ -209,29 +382,11 @@ var ThemeCustomizeDialog = Dialog.extend({
* @private
*/
_loadViews: function () {
- var self = this;
return this._rpc({
route: '/website/theme_customize_get',
params: {
- xml_ids: this._getXMLIDs(this.$inputs),
+ 'xml_ids': this._getXMLIDs(this.$inputs || this.$('[data-xmlid]')),
},
- }).done(function (data) {
- self.$inputs.prop('checked', false);
- _.each(self.$inputs.filter('[data-xmlid]:not([data-xmlid=""])'), function (input) {
- var $input = $(input);
- if (!_.difference(self._getXMLIDs($input), data[0]).length) {
- $input.prop('checked', true);
- }
- });
- _.each(self.$inputs.filter('[data-xmlid=""]'), function (input) {
- var $input = $(input);
- if (!self.$inputs.filter('[name="' + $input.attr('name') + '"]:checked').length) {
- $input.prop('checked', true);
- }
- });
- self._setActive();
- }).fail(function (d, error) {
- Dialog.alert(this, error.data.message);
});
},
/**
@@ -260,49 +415,112 @@ var ThemeCustomizeDialog = Dialog.extend({
/**
* @private
*/
- _processChange: function ($inputs) {
+ _makeSCSSCusto: function (url, values) {
+ return this._rpc({
+ route: '/website/make_scss_custo',
+ params: {
+ 'url': url,
+ 'values': values,
+ },
+ });
+ },
+ /**
+ * @private
+ */
+ _pickColor: function (colorElement) {
var self = this;
- this.$modal.addClass('o_theme_customize_loading');
-
- var bodyCustomImageXMLID = 'option_custom_body_image';
- var $inputBodyCustomImage = $inputs.filter('[data-xmlid*="website.' + bodyCustomImageXMLID + '"]:checked');
- if (!$inputBodyCustomImage.length) {
- return $.when();
- }
+ var $color = $(colorElement);
+ var colorName = $color.data('color');
+ var colorType = $color.data('colorType');
var def = $.Deferred();
- var $image = $('');
- var editor = new weWidgets.MediaDialog(this, {
- onlyImages: true,
- firstFilters: ['background'],
- }, $image[0]);
- editor.on('save', this, function (media) { // TODO use scss customization instead (like for user colors)
- var src = $(media).attr('src');
- self._rpc({
- model: 'ir.model.data',
- method: 'get_object_reference',
- args: ['website', bodyCustomImageXMLID],
- }).then(function (data) {
- return self._rpc({
- model: 'ir.ui.view',
- method: 'save',
- args: [
- data[1],
- '#wrapwrap { background-image: url("' + src + '"); }',
- '//style',
- ],
- });
- }).then(function () {
+ var colorpicker = new ColorpickerDialog(this, {
+ defaultColor: $color.css('background-color'),
+ });
+ var chosenColor = undefined;
+ colorpicker.on('colorpicker:saved', this, function (ev) {
+ ev.stopPropagation();
+ chosenColor = ev.data.cssColor;
+ });
+ colorpicker.on('closed', this, function (ev) {
+ if (chosenColor === undefined) {
def.resolve();
- });
- });
- editor.on('cancel', this, function () {
- def.resolve();
- });
+ return;
+ }
- editor.open();
+ var baseURL = '/website/static/src/scss/options/colors/';
+ var url = _.str.sprintf('%suser_%scolor_palette.scss', baseURL, (colorType ? (colorType + '_') : ''));
+ var colors = {};
+ colors[colorName] = chosenColor;
+ if (colorName === 'alpha') {
+ colors['beta'] = 'null';
+ colors['gamma'] = 'null';
+ colors['delta'] = 'null';
+ colors['epsilon'] = 'null';
+ }
+
+ self._makeSCSSCusto(url, colors).always(def.resolve.bind(def));
+ });
+ colorpicker.open();
+
+ return def;
+ },
+ /**
+ * @private
+ */
+ _processChange: function ($inputs) {
+ var self = this;
+ var defs = [];
+
+ var $options = $inputs.closest('.o_theme_customize_option');
+
+ // Handle body image changes
+ var $bodyImageInputs = $inputs.filter('[data-xmlid*="website.' + this.CUSTOM_BODY_IMAGE_XML_ID + '"]:checked');
+ defs = defs.concat(_.map($bodyImageInputs, function () {
+ return self._chooseBodyCustomImage();
+ }));
+
+ // Handle color changes
+ var $colors = $options.find('.o_theme_customize_color');
+ defs = defs.concat(_.map($colors, function (colorElement) {
+ return self._pickColor($(colorElement));
+ }));
+
+ // Handle input changes
+ var $inputsData = $options.find('.o_theme_customize_input');
+ defs = defs.concat(_.map($inputsData, function (inputData, i) {
+ return self._quickEdit($(inputData));
+ }));
+
+ return $.when.apply($, defs);
+ },
+ /**
+ * @private
+ */
+ _quickEdit: function ($inputData) {
+ var text = $inputData.text().trim();
+ var value = parseFloat(text) || '';
+ var unit = text.match(/([^\s\d]+)$/)[1];
+
+ var def = $.Deferred();
+ var qEdit = new QuickEdit(this, value, unit);
+ qEdit.on('QuickEdit:save', this, function (ev) {
+ ev.stopPropagation();
+
+ var value = ev.data.value;
+ // Convert back to rem if needed
+ if ($inputData.data('unit') === 'rem' && unit === 'px' && value !== 'null') {
+ value = parseFloat(value) / this.PX_BY_REM + 'rem';
+ }
+
+ var values = {};
+ values[$inputData.data('value')] = value;
+ this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', values)
+ .always(def.resolve.bind(def));
+ });
+ qEdit.appendTo($inputData.closest('.o_theme_customize_option'));
return def;
},
/**
@@ -312,11 +530,11 @@ var ThemeCustomizeDialog = Dialog.extend({
var self = this;
// Look at all options to see if they are enabled or disabled
- var $enable = this.$inputs.filter('[data-xmlid]:checked');
+ var $enable = this.$inputs.filter(':checked');
// Mark the labels as checked accordingly
this.$('label').removeClass('checked');
- $enable.closest('label').addClass('checked');
+ $enable.closest('label:not(.o_switch)').addClass('checked');
// Mark the option sets as checked if all their option are checked/unchecked
var $sets = this.$inputs.filter('[data-enable], [data-disable]').not('[data-xmlid]');
@@ -329,15 +547,21 @@ var ThemeCustomizeDialog = Dialog.extend({
if (self._getInputs($set.data('disable')).filter(':checked').length) {
checked = false;
}
- $set.prop('checked', checked).closest('label').toggleClass('checked', checked);
+ $set.prop('checked', checked).closest('label:not(.o_switch)').toggleClass('checked', checked);
});
// Make the hidden sections visible if their dependencies are met
_.each(this.$('[data-depends]'), function (hidden) {
var $hidden = $(hidden);
var depends = $hidden.data('depends');
- var nbDependencies = depends ? depends.split(',').length : 0;
- var enabled = self._getInputs(depends).filter(':checked').length === nbDependencies;
+ var dependencies = depends ? depends.split(/\s*,\s*/g) : [];
+ var enabled = _.all(dependencies, function (dep) {
+ var toBeChecked = (dep[0] !== '!');
+ if (!toBeChecked) {
+ dep = dep.substr(1);
+ }
+ return self._getInputs(dep).is(':checked') === toBeChecked;
+ });
$hidden.toggleClass('d-none', !enabled);
});
},
@@ -345,6 +569,11 @@ var ThemeCustomizeDialog = Dialog.extend({
* @private
*/
_updateStyle: function (enable, disable, reload) {
+ var self = this;
+
+ var $loading = $('', {class: 'fa fa-refresh fa-spin'});
+ this.$modal.find('.modal-title').append($loading);
+
if (reload || config.debug === 'assets') {
window.location.href = $.param.querystring('/website/theme_customize_reload', {
href: window.location.href,
@@ -355,18 +584,19 @@ var ThemeCustomizeDialog = Dialog.extend({
return $.Deferred();
}
- var self = this;
return this._rpc({
route: '/website/theme_customize',
params: {
- enable: enable,
- disable: disable,
- get_bundle: true,
+ 'enable': enable,
+ 'disable': disable,
+ 'get_bundle': true,
},
}).then(function (bundles) {
+ var $allLinks = $();
var defs = _.map(bundles, function (bundleContent, bundleName) {
var linkSelector = 'link[href*="' + bundleName + '"]';
var $links = $(linkSelector);
+ $allLinks = $allLinks.add($links);
var $newLinks = $(bundleContent).filter(linkSelector);
var linksLoaded = $.Deferred();
@@ -382,14 +612,51 @@ var ThemeCustomizeDialog = Dialog.extend({
window.location.reload();
});
$links.last().after($newLinks);
- return linksLoaded.then(function () {
- $links.remove();
- });
+ return linksLoaded;
});
+ return $.when.apply($, defs).always(function () {
+ $loading.remove();
+ $allLinks.remove();
+ });
+ }).then(function () {
+ // Some animations may depend on the variables that were
+ // customized, so we have to restart them.
+ self.trigger_up('animation_start_demand');
+ });
+ },
+ /**
+ * @private
+ */
+ _updateValues: function () {
+ var self = this;
+ // Put user values
+ _.each(this.$('.o_theme_customize_color'), function (el) {
+ var $el = $(el);
+ var value = self.style.getPropertyValue('--' + $el.data('color')).trim();
+ $el.css('background-color', value);
+ });
+ _.each(this.$('.o_theme_customize_input'), function (el) {
+ var $el = $(el);
+ var value = self.style.getPropertyValue('--' + $el.data('value')).trim();
- return $.when.apply($, defs).then(function () {
- self.$modal.removeClass('o_theme_customize_loading');
- });
+ // Convert rem values to px values
+ if (_.str.endsWith(value, 'rem')) {
+ value = parseFloat(value) * self.PX_BY_REM + 'px';
+ }
+
+ var $span = $el.find('span');
+ $span.removeClass().text('');
+ switch (value) {
+ case '':
+ case 'false':
+ case 'true':
+ // When null or a boolean value, shows an icon which tells
+ // the user that there is no numeric/text value
+ $span.addClass('fa fa-ban text-danger');
+ break;
+ default:
+ $span.text(value);
+ }
});
},
@@ -406,6 +673,11 @@ var ThemeCustomizeDialog = Dialog.extend({
// Checkout the option that changed
var $option = $(ev.currentTarget);
+ if ($option.is(':disabled')) {
+ return;
+ }
+ this.$inputs.prop('disabled', true);
+
var $options = $option;
var checked = $option.is(':checked');
@@ -437,91 +709,21 @@ var ThemeCustomizeDialog = Dialog.extend({
// Update the style according to the whole set of options
self._processChange($options).then(function () {
- self._updateStyle(
+ return self._updateStyle(
self._getXMLIDs($enable),
self._getXMLIDs($disable),
$option.data('reload') && window.location.href.match(new RegExp($option.data('reload')))
);
+ }).then(function () {
+ self._updateValues();
+ self.$inputs.prop('disabled', false);
});
},
- /**
- * @private
- * @param {Event} ev
- */
- _onColorClick: function (ev) {
- var self = this;
- var $color = $(ev.currentTarget);
- var colorName = $color.data('color');
- var colorType = $color.data('colorType');
-
- var colorpicker = new ColorpickerDialog(this, {
- defaultColor: $color.find('.o_color_preview').css('background-color'),
- });
- colorpicker.on('colorpicker:saved', this, function (ev) {
- ev.stopPropagation();
-
- // TODO improve to be more efficient
- self._rpc({
- route: '/web_editor/get_assets_editor_resources',
- params: {
- key: 'website.layout',
- get_views: false,
- get_scss: true,
- get_js: false,
- bundles: false,
- bundles_restriction: [],
- only_user_custom_files: false,
- },
- }).then(function (data) {
- var files = data.scss[0][1];
- var file = _.find(files, function (file) {
- var baseURL = '/website/static/src/scss/options/colors/';
- return file.url === _.str.sprintf('%suser_%scolor_palette.scss', baseURL, (colorType ? (colorType + '_') : ''));
- });
-
- var colors = {};
- colors[colorName] = ev.data.cssColor;
- if (colorName === 'alpha') {
- colors['beta'] = 'null';
- colors['gamma'] = 'null';
- colors['delta'] = 'null';
- colors['epsilon'] = 'null';
- }
-
- var updatedFileContent = file.arch;
- _.each(colors, function (colorValue, colorName) {
- var pattern = _.str.sprintf("'%s': %%s,\n", colorName);
- var regex = new RegExp(_.str.sprintf(pattern, ".+"));
- var replacement = _.str.sprintf(pattern, colorValue);
- if (regex.test(updatedFileContent)) {
- updatedFileContent = updatedFileContent
- .replace(regex, replacement);
- } else {
- updatedFileContent = updatedFileContent
- .replace(/( *)(.*hook.*)/, _.str.sprintf('$1%s$1$2', replacement));
- }
- });
-
- return self._rpc({
- route: '/web_editor/save_scss_or_js',
- params: {
- url: file.url,
- bundle_xmlid: 'web.assets_common',
- content: updatedFileContent,
- file_type: 'scss',
- },
- });
- }).then(function () {
- return self._updateStyle();
- });
- });
- colorpicker.open();
- },
});
var ThemeCustomizeMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, {
- customize_theme: '_openThemeCustomizeDialog',
+ 'customize_theme': '_openThemeCustomizeDialog',
}),
/**
diff --git a/addons/website/static/src/scss/options/user_values.scss b/addons/website/static/src/scss/options/user_values.scss
new file mode 100644
index 00000000000..3b6899ad091
--- /dev/null
+++ b/addons/website/static/src/scss/options/user_values.scss
@@ -0,0 +1,7 @@
+// This file is meant to be edited automatically by the user. The variables it
+// contains should not be renamed otherwise it would break existing customers
+// customizations.
+
+$o-user-website-values: map-merge($o-user-website-values, o-map-omit((
+ // -- hook --
+)));
diff --git a/addons/website/static/src/scss/primary_variables.scss b/addons/website/static/src/scss/primary_variables.scss
index 835acc6b3f9..72bb93ba8e6 100644
--- a/addons/website/static/src/scss/primary_variables.scss
+++ b/addons/website/static/src/scss/primary_variables.scss
@@ -40,6 +40,22 @@ $o-social-colors: (
$o-theme-figcaption-opacity: 0.6;
+//------------------------------------------------------------------------------
+// Website customizations
+//------------------------------------------------------------------------------
+
+$o-website-values-palettes: (
+ (
+ 'logo-height': null, // Default to navbar height (see portal)
+ 'header-font-size': null, // Default to BS (normal font-size)
+ ),
+) !default;
+$o-website-values-palette-number: 1 !default;
+
+// By default, all user website values are null. Each null value is
+// automatically replaced with corresponsing value of chosen values palette.
+$o-user-website-values: () !default;
+
//------------------------------------------------------------------------------
// Fonts
//------------------------------------------------------------------------------
diff --git a/addons/website/static/src/scss/secondary_variables.scss b/addons/website/static/src/scss/secondary_variables.scss
index 517a541c99a..168965c56f1 100644
--- a/addons/website/static/src/scss/secondary_variables.scss
+++ b/addons/website/static/src/scss/secondary_variables.scss
@@ -34,6 +34,20 @@ $o-theme-color-palettes: append($o-theme-color-palettes, map-merge($-palette-def
// Enable last color and theme color palettes, which are now the user customized
-// color palettes
+// color palettes.
$o-color-palette-number: length($o-color-palettes);
$o-theme-color-palette-number: length($o-theme-color-palettes);
+
+//------------------------------------------------------------------------------
+// Website customizations
+//------------------------------------------------------------------------------
+
+$-website-values-default: nth($o-website-values-palettes, $o-website-values-palette-number);
+$o-website-values: map-merge($-website-values-default, o-map-omit($o-user-website-values)) !default;
+$o-website-values-palettes: append($o-website-values-palettes, $o-website-values);
+$o-website-values-palette-number: length($o-website-values-palettes);
+@function o-website-value($key) {
+ @return map-get($o-website-values, $key);
+}
+
+$o-theme-navbar-logo-height: o-website-value('logo-height') !default;
diff --git a/addons/website/static/src/scss/website.edit_mode.scss b/addons/website/static/src/scss/website.edit_mode.scss
index 85ee8dc8fd4..664dcf037d0 100644
--- a/addons/website/static/src/scss/website.edit_mode.scss
+++ b/addons/website/static/src/scss/website.edit_mode.scss
@@ -6,17 +6,6 @@
}
.modal.o_theme_customize_modal {
-
- &.o_theme_customize_loading {
- pointer-events: none;
-
- &::after {
- content: "";
- @include o-position-absolute(0, 0, 0, 0);
- background-color: rgba(0, 0, 0, 0.2);
- }
- }
-
.modal-dialog {
@include o-position-absolute(0, 2%);
width: 96%;
@@ -71,25 +60,40 @@
+ label {
@include o-position-absolute(0, 0, 0, 0);
width: auto;
- background: transparent;
+ background: transparent !important;
}
}
label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
width: 100%;
min-height: 24px;
margin: 0;
padding: 8px;
border: 2px solid transparent;
background-color: $o-brand-lightsecondary;
+ text-align: center;
font-size: 14px;
line-height: 1;
cursor: pointer;
+ &:hover::after {
+ content: "";
+ pointer-events: none;
+ @include o-position-absolute(0, 0, 0, 0);
+ background-color: rgba(0, 32, 64, 0.03);
+ }
+
&.checked {
border-color: $o-brand-odoo;
color: $o-brand-odoo;
}
+
+ &.o_switch {
+ justify-content: flex-start;
+ }
}
$i: 1;
@@ -103,57 +107,68 @@
}
$i: $i + 1;
}
- }
- .o_theme_customize_color_previews {
- h6, a {
- font-size: 12px;
- margin: 4px 0 0;
- }
+ &.o_theme_customize_with_widget {
+ label {
+ justify-content: flex-start;
+ text-align: left;
- .o_theme_customize_color {
- > .o_color_preview {
- position: relative;
- display: inline-block;
- width: 48px;
- height: 27px;
- border: 1px solid black;
-
- &::before, &::after {
- content: "";
- @include o-position-absolute(0, 0, 0, 0);
- }
- &::before {
- background-image: url('/web_editor/static/src/img/transparent.png');
- }
- &::after {
- background-color: inherit;
+ &.checked {
+ border-color: transparent;
+ color: inherit;
}
}
+ }
+ }
- $palette: nth($o-color-palettes, $o-color-palette-number);
- $-text-color: if(map-get($palette, 'text'), map-get($palette, 'text'), #212529); // BS default as fallback (TODO find real value)
- $-h1-color: if(map-get($palette, 'h1'), map-get($palette, 'h1'), $-text-color);
- $-h2-color: if(map-get($palette, 'h2'), map-get($palette, 'h2'), $-h1-color);
- $-h3-color: if(map-get($palette, 'h3'), map-get($palette, 'h3'), $-h2-color);
- $-h4-color: if(map-get($palette, 'h4'), map-get($palette, 'h4'), $-h3-color);
- $-h5-color: if(map-get($palette, 'h5'), map-get($palette, 'h5'), $-h4-color);
- $-h6-color: if(map-get($palette, 'h6'), map-get($palette, 'h6'), $-h5-color);
- $-defaults: (
- 'body': white, // BS default as fallback (TODO find real value)
- 'text': $-text-color,
- 'h1': $-h1-color,
- 'h2': $-h2-color,
- 'h3': $-h3-color,
- 'h4': $-h4-color,
- 'h5': $-h5-color,
- 'h6': $-h6-color,
- );
+ .o_theme_customize_color {
+ position: relative;
+ width: 30px;
+ height: 20px;
+ margin-left: auto;
+ border: 1px solid black;
- @each $name, $value in map-merge($-defaults, o-map-omit($palette)) {
- &.o_theme_customize_color_#{$name} > .o_color_preview {
- background-color: $value;
- }
+ &::before, &::after {
+ content: "";
+ @include o-position-absolute(0, 0, 0, 0);
+ }
+ &::before {
+ background-image: url('/web_editor/static/src/img/transparent.png');
+ }
+ &::after {
+ background-color: inherit;
+ }
+ }
+
+ .o_theme_customize_input {
+ flex: 1 1 auto;
+ text-align: right;
+
+ .fa-edit {
+ visibility: hidden;
+ color: $o-brand-primary;
+ }
+ }
+ .o_theme_customize_option:hover .o_theme_customize_input .fa-edit {
+ visibility: visible;
+ }
+
+ .o_theme_customize_active_input {
+ @include o-position-absolute(0, 0, 0, 0);
+ width: auto;
+ padding: 3px;
+
+ > * {
+ height: 100% !important;
+
+ &.form-control {
+ background: white !important;
+ }
+
+ &.form-control, .btn {
+ padding: 2px 8px !important;
+ text-align: right !important;
+ font-size: $font-size-sm !important;
}
}
}
diff --git a/addons/website/static/src/scss/website.scss b/addons/website/static/src/scss/website.scss
index 71619d3aaa3..2aec4f03251 100644
--- a/addons/website/static/src/scss/website.scss
+++ b/addons/website/static/src/scss/website.scss
@@ -19,6 +19,44 @@ $-font-numbers: (
}
}
+:root {
+ // The theme customize modal JS will need to know the value of some scss
+ // variables used to render the user website, and those may have been
+ // customized by themes, the user or anything else (so there is no file to
+ // parse to get them). Those will be printed here as CSS variables.
+
+ // 1) Handle default values
+ --header-font-size: #{$font-size-base};
+
+ // 2) The values in the $theme-colors map are already printed by Bootstrap.
+
+ // 3) The values in the $colors map are also printed by Bootstrap.
+
+ // 4) The Odoo values map, $o-website-values, must be printed.
+ @each $key, $value in $o-website-values {
+ --#{$key}: #{$value};
+ }
+
+ // 5) Use final value used by the theme
+ $-h1-color: if($headings-color == inherit, $body-color, $headings-color);
+ $-h2-color: if(color('h2'), color('h2'), $-h1-color);
+ $-h3-color: if(color('h3'), color('h3'), $-h2-color);
+ $-h4-color: if(color('h4'), color('h4'), $-h3-color);
+ $-h5-color: if(color('h5'), color('h5'), $-h4-color);
+ $-h6-color: if(color('h6'), color('h6'), $-h5-color);
+
+ --body: #{$body-bg};
+ --text: #{$body-color};
+ --h1: #{$-h1-color};
+ --h2: #{$-h2-color};
+ --h3: #{$-h3-color};
+ --h4: #{$-h4-color};
+ --h5: #{$-h5-color};
+ --h6: #{$-h6-color};
+
+ --logo-height: #{$o-theme-navbar-logo-height};
+}
+
#wrapwrap {
background-size: cover;
background-repeat: no-repeat;
@@ -64,6 +102,31 @@ $-font-numbers: (
}
}
+$-header-nav-link-height: $nav-link-height;
+@if o-website-value('header-font-size') {
+ $-header-nav-link-height: o-website-value('header-font-size') * $line-height-base + $nav-link-padding-y * 2;
+ header {
+ font-size: o-website-value('header-font-size');
+
+ .dropdown-menu {
+ font-size: inherit;
+ }
+ }
+}
+@if $o-theme-navbar-logo-height {
+ // With default values, this makes it slightly bigger than standard
+ // navbar-brand, which is what we want
+ header .navbar-brand {
+ font-size: $o-theme-navbar-logo-height / $line-height-base;
+
+ $-logo-padding-y: max(0, $-header-nav-link-height - $o-theme-navbar-logo-height) / 2;
+ &, &.logo {
+ padding-top: $-logo-padding-y;
+ padding-bottom: $-logo-padding-y;
+ }
+ }
+}
+
.o_footer {
@if color('footer') {
@include o-bg-color(color('footer'));
@@ -985,10 +1048,10 @@ table.table_desc tr td {
position: fixed;
z-index: $zindex-modal - 20;
background: $light;
- margin-top: -200px;
+ margin-top: -999px;
transition: margin-top 500ms ease 0s;
&.affixed {
- margin-top: 0px;
+ margin-top: 0px !important; // the default margin-top is adapted in JS
}
}
#oe_main_menu_navbar + #wrapwrap .o_header_affix {
diff --git a/addons/website/static/src/xml/website.editor.xml b/addons/website/static/src/xml/website.editor.xml
index 46d5de05c12..2f8473d90c3 100644
--- a/addons/website/static/src/xml/website.editor.xml
+++ b/addons/website/static/src/xml/website.editor.xml
@@ -22,9 +22,9 @@
@@ -37,54 +37,39 @@
-
+
-
-
-
-
-
Main colors
-
-
-
-
-
-
-
-
Background colors
-
-
-
-
-
-
Text colors
-
-
-
-
-
-
State colors
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
diff --git a/addons/website/views/website_navbar_templates.xml b/addons/website/views/website_navbar_templates.xml
index 5406c3d2ad3..34623a7c3a1 100644
--- a/addons/website/views/website_navbar_templates.xml
+++ b/addons/website/views/website_navbar_templates.xml
@@ -52,7 +52,6 @@
Customize ThemeHTML/CSS/JS EditorAdd Features
-
-
+
@@ -305,7 +308,7 @@
-
+
@@ -317,10 +320,6 @@
-
-
-
-
@@ -448,7 +447,7 @@
-> string = tab's nav text
-> title = tab's heading
- Allowed tags in the elements:
+ In the elements:
: Declares a new toggle option
-> id (optional) = an ID to associate to the option
@@ -476,12 +475,61 @@
: Declares a subset of options showed as a list
-> string = list's title
-> data-col (optional, default 6) = bootstrap column size for the list
+
+ Other elements will be ignored and their children will be processed as if
+ that element was omitted, except that those children will be considered as
+ in a new set of option. That allows to wrap an option in a fake element to
+ indicate it is a standalone option for example.
+
+ Note: if an option is the only one of its group, a toggle element will be
+ rendered.
-->