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 Theme HTML/CSS/JS Editor Add Features - diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index e4598c5d52c..809576083c1 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -4,6 +4,9 @@ -