summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/web_editor/controllers/main.py91
-rw-r--r--addons/web_editor/static/src/img/curved_arrow.svg14
-rw-r--r--addons/web_editor/static/src/js/common/utils.js13
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.editor.js7
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.options.js121
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/media.js195
-rw-r--r--addons/web_editor/static/src/scss/web_editor.common.scss25
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg.scss9
-rw-r--r--addons/web_editor/static/src/xml/wysiwyg.xml39
-rw-r--r--addons/web_editor/static/tests/field_html_tests.js8
-rw-r--r--addons/web_editor/static/tests/test_utils.js4
-rw-r--r--addons/web_editor/views/snippets.xml9
-rw-r--r--addons/web_unsplash/static/src/js/unsplash_image_widget.js31
-rw-r--r--addons/web_unsplash/static/src/scss/unsplash.scss11
-rw-r--r--addons/web_unsplash/static/src/xml/unsplash_image_widget.xml20
-rw-r--r--addons/website/static/src/js/widgets/media.js14
-rw-r--r--addons/website/views/assets.xml2
-rw-r--r--addons/website_blog/static/src/js/tours/website_blog.js8
-rw-r--r--addons/website_blog/tests/test_ui.py7
19 files changed, 552 insertions, 76 deletions
diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py
index 5285af88c2c..e79751eeb79 100644
--- a/addons/web_editor/controllers/main.py
+++ b/addons/web_editor/controllers/main.py
@@ -4,18 +4,21 @@ import io
import logging
import re
import time
+import requests
import werkzeug.wrappers
from PIL import Image, ImageFont, ImageDraw
from lxml import etree
-from base64 import b64decode
+from base64 import b64decode, b64encode
from odoo.http import request
-from odoo import http, tools, _
+from odoo import http, tools, _, SUPERUSER_ID
+from odoo.addons.http_routing.models.ir_http import slug
from odoo.exceptions import UserError
-from odoo.modules.module import get_resource_path
+from odoo.modules.module import get_module_path, get_resource_path
+from odoo.tools.misc import file_open
logger = logging.getLogger(__name__)
-
+DEFAULT_LIBRARY_ENDPOINT = 'https://media-api.odoo.com'
class Web_Editor(http.Controller):
#------------------------------------------------------
@@ -505,24 +508,30 @@ class Web_Editor(http.Controller):
return '%s?access_token=%s' % (attachment.image_src, attachment.access_token)
@http.route(['/web_editor/shape/<module>/<path:filename>'], type='http', auth="public", website=True)
- def background_shape(self, module, filename, **kwargs):
+ def shape(self, module, filename, **kwargs):
"""
- Returns a color-customized background-shape.
+ Returns a color-customized svg (background shape or illustration).
"""
- shape_path = get_resource_path(module, 'static', 'shapes', filename)
- if shape_path:
- svg = open(shape_path, 'r').read()
- else:
- # Fallback to attachments for future migrations
- attachment = request.env['ir.attachment'].search([('url', '=like', request.httprequest.path)], limit=1)
+ svg = None
+ if module == 'illustration':
+ attachment = request.env['ir.attachment'].sudo().search([('url', '=like', request.httprequest.path), ('public', '=', True)], limit=1)
if not attachment:
raise werkzeug.exceptions.NotFound()
svg = b64decode(attachment.datas).decode('utf-8')
+ else:
+ shape_path = get_resource_path(module, 'static', 'shapes', filename)
+ if not shape_path:
+ raise werkzeug.exceptions.NotFound()
+ with tools.file_open(shape_path, 'r') as file:
+ svg = file.read()
user_colors = []
for key, color in kwargs.items():
- match = re.match('^c([12345])$', key)
+ match = re.match('^c([1-5])$', key)
if match:
+ # Check that color is hex or rgb(a) to prevent arbitrary injection
+ if not re.match(r'(?i)^#[0-9A-F]{6,8}$|^rgba?\(\d{1,3},\d{1,3},\d{1,3}(?:,[0-9.]{1,4})?\)$', color.replace(' ', '')):
+ raise werkzeug.exceptions.BadRequest()
user_colors.append([tools.html_escape(color), match.group(1)])
default_palette = {
@@ -545,3 +554,59 @@ class Web_Editor(http.Controller):
('Content-type', 'image/svg+xml'),
('Cache-control', 'max-age=%s' % http.STATIC_CACHE_LONG),
])
+
+ @http.route(['/web_editor/media_library_search'], type='json', auth="user", website=True)
+ def media_library_search(self, **params):
+ ICP = request.env['ir.config_parameter'].sudo()
+ endpoint = ICP.get_param('web_editor.media_library_endpoint', DEFAULT_LIBRARY_ENDPOINT)
+ params['dbuuid'] = ICP.get_param('database.uuid')
+ response = requests.post('%s/media-library/1/search' % endpoint, data=params)
+ if response.status_code == requests.codes.ok and response.headers['content-type'] == 'application/json':
+ return response.json()
+ else:
+ return {'error': response.status_code}
+
+ @http.route('/web_editor/save_library_media', type='json', auth='user', methods=['POST'])
+ def save_library_media(self, media):
+ """
+ Saves images from the media library as new attachments, making them
+ dynamic SVGs if needed.
+ media = {
+ <media_id>: {
+ 'query': 'space separated search terms',
+ 'is_dynamic_svg': True/False,
+ }, ...
+ }
+ """
+ attachments = []
+ ICP = request.env['ir.config_parameter'].sudo()
+ library_endpoint = ICP.get_param('web_editor.media_library_endpoint', DEFAULT_LIBRARY_ENDPOINT)
+
+ media_ids = ','.join(media.keys())
+ params = {
+ 'dbuuid': ICP.get_param('database.uuid'),
+ 'media_ids': media_ids,
+ }
+ response = requests.post('%s/media-library/1/download_urls' % library_endpoint, data=params)
+ if response.status_code != requests.codes.ok:
+ raise Exception(_("ERROR: couldn't get download urls from media library."))
+
+ for id, url in response.json().items():
+ req = requests.get(url)
+ name = '_'.join([media[id]['query'], url.split('/')[-1]])
+ # Need to bypass security check to write image with mimetype image/svg+xml
+ # ok because svgs come from whitelisted origin
+ context = {'binary_field_real_user': request.env['res.users'].sudo().browse([SUPERUSER_ID])}
+ attachment = request.env['ir.attachment'].sudo().with_context(context).create({
+ 'name': name,
+ 'mimetype': req.headers['content-type'],
+ 'datas': b64encode(req.content),
+ 'public': True,
+ 'res_model': 'ir.ui.view',
+ 'res_id': 0,
+ })
+ if media[id]['is_dynamic_svg']:
+ attachment['url'] = '/web_editor/shape/illustration/%s' % slug(attachment)
+ attachments.append(attachment._get_media_info())
+
+ return attachments
diff --git a/addons/web_editor/static/src/img/curved_arrow.svg b/addons/web_editor/static/src/img/curved_arrow.svg
new file mode 100644
index 00000000000..a8ed9acd242
--- /dev/null
+++ b/addons/web_editor/static/src/img/curved_arrow.svg
@@ -0,0 +1,14 @@
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1144 1280">
+<g transform="translate(0,1280) scale(0.1,-0.1)" fill="#000000" stroke="none">
+<path d="M7166 11004 c-603 -901 -1094 -1639 -1092 -1641 1 -2 369 42 817 97
+448 55 825 101 838 103 30 3 26 14 86 -278 131 -634 218 -1317 255 -2010 13
+-246 13 -898 0 -1135 -71 -1262 -367 -2292 -883 -3065 -162 -242 -308 -418
+-541 -650 -661 -658 -1465 -1094 -2581 -1400 -1069 -293 -2283 -471 -3660
+-536 -132 -6 -242 -13 -244 -15 -6 -5 16 -190 30 -255 9 -43 13 -47 47 -53
+108 -17 1148 -2 1627 24 2760 152 4778 866 6094 2155 506 496 887 1018 1216
+1670 444 877 715 1860 819 2970 35 365 41 510 41 1000 0 581 -17 880 -76 1355
+-19 158 -65 452 -74 477 -5 14 -54 7 730 104 363 44 662 83 665 85 2 3 0 8 -6
+12 -6 4 -682 594 -1503 1312 -822 718 -1497 1306 -1501 1308 -4 2 -501 -734
+-1104 -1634z"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/src/js/common/utils.js b/addons/web_editor/static/src/js/common/utils.js
index c66bb75a47c..6e92bd6955b 100644
--- a/addons/web_editor/static/src/js/common/utils.js
+++ b/addons/web_editor/static/src/js/common/utils.js
@@ -32,6 +32,18 @@ const CSS_UNITS_CONVERSION = {
'rem-px': () => _computePxByRem(),
'px-rem': () => _computePxByRem(true),
};
+/**
+ * Colors of the default palette, used for substitution in shapes/illustrations.
+ * key: number of the color in the palette (ie, o-color-<1-5>)
+ * value: color hex code
+ */
+const DEFAULT_PALETTE = {
+ '1': '#3AADAA',
+ '2': '#7C6576',
+ '3': '#F6F6F6',
+ '4': '#FFFFFF',
+ '5': '#383E45',
+};
/**
* Computes the number of "px" needed to make a "rem" unit. Subsequent calls
@@ -218,6 +230,7 @@ function _getBgImageURL(el) {
return {
CSS_SHORTHANDS: CSS_SHORTHANDS,
CSS_UNITS_CONVERSION: CSS_UNITS_CONVERSION,
+ DEFAULT_PALETTE: DEFAULT_PALETTE,
computePxByRem: _computePxByRem,
convertValueToUnit: _convertValueToUnit,
convertNumericToUnit: _convertNumericToUnit,
diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js
index 4254afe270f..37eda9910d1 100644
--- a/addons/web_editor/static/src/js/editor/snippets.editor.js
+++ b/addons/web_editor/static/src/js/editor/snippets.editor.js
@@ -10,6 +10,7 @@ var options = require('web_editor.snippets.options');
var Wysiwyg = require('web_editor.wysiwyg');
const {ColorPaletteWidget} = require('web_editor.ColorPalette');
const SmoothScrollOnDrag = require('web/static/src/js/core/smooth_scroll_on_drag.js');
+const {getCSSVariableValue} = require('web_editor.utils');
var _t = core._t;
@@ -1853,6 +1854,12 @@ var SnippetsMenu = Widget.extend({
}
$toInsert = $baseBody.clone();
+ // Color-customize dynamic SVGs in dropped snippets with current theme colors.
+ [...$toInsert.find('img[src^="/web_editor/shape/"]')].forEach(dynamicSvg => {
+ const colorCustomizedURL = new URL(dynamicSvg.getAttribute('src'), window.location.origin);
+ colorCustomizedURL.searchParams.set('c1', getCSSVariableValue('o-color-1'));
+ dynamicSvg.src = colorCustomizedURL.pathname + colorCustomizedURL.search;
+ });
if (!$selectorSiblings.length && !$selectorChildren.length) {
console.warn($snippet.find('.oe_snippet_thumbnail_title').text() + " have not insert action: data-drop-near or data-drop-in");
diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js
index 8e236bb93d1..1a0e8d34935 100644
--- a/addons/web_editor/static/src/js/editor/snippets.options.js
+++ b/addons/web_editor/static/src/js/editor/snippets.options.js
@@ -3578,6 +3578,30 @@ registry.BackgroundImage = SnippetOptionWidget.extend({
this.$target.trigger('background_changed', [previewMode]);
}
},
+ /**
+ * Changes the main color of dynamic SVGs.
+ *
+ * @see this.selectClass for parameters
+ */
+ async dynamicColor(previewMode, widgetValue, params) {
+ const currentSrc = getBgImageURL(this.$target[0]);
+ switch (previewMode) {
+ case true:
+ this.previousSrc = currentSrc;
+ break;
+ case 'reset':
+ this.$target.css('background-image', `url('${this.previousSrc}')`);
+ return;
+ }
+ const newURL = new URL(currentSrc, window.location.origin);
+ newURL.searchParams.set('c1', normalizeColor(widgetValue));
+ const src = newURL.pathname + newURL.search;
+ await loadImage(src);
+ this.$target.css('background-image', `url('${src}')`);
+ if (!previewMode) {
+ this.previousSrc = src;
+ }
+ },
//--------------------------------------------------------------------------
// Public
@@ -3608,8 +3632,21 @@ registry.BackgroundImage = SnippetOptionWidget.extend({
* @override
*/
_computeWidgetState: function (methodName) {
- if (methodName === 'background') {
- return getBgImageURL(this.$target[0]);
+ switch (methodName) {
+ case 'background':
+ return getBgImageURL(this.$target[0]);
+ case 'dynamicColor':
+ return new URL(getBgImageURL(this.$target[0]), window.location.origin).searchParams.get('c1');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _computeWidgetVisibility(widgetName, params) {
+ if (widgetName === 'dynamic_color_opt') {
+ const src = new URL(getBgImageURL(this.$target[0]), window.location.origin);
+ return src.origin === window.location.origin && src.pathname.startsWith('/web_editor/shape/');
}
return this._super(...arguments);
},
@@ -4505,6 +4542,86 @@ registry.SnippetSave = SnippetOptionWidget.extend({
},
});
+/**
+ * Handles the dynamic colors for dynamic SVGs.
+ */
+registry.DynamicSvg = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('image_changed.DynamicSvg', this._onImageChanged.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this.$target.off('.DynamicSvg');
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the dynamic SVG's dynamic color.
+ *
+ * @see this.selectClass for params
+ */
+ async color(previewMode, widgetValue, params) {
+ const target = this.$target[0];
+ switch (previewMode) {
+ case true:
+ this.previousSrc = target.getAttribute('src');
+ break;
+ case 'reset':
+ target.src = this.previousSrc;
+ return;
+ }
+ const newURL = new URL(target.src, window.location.origin);
+ newURL.searchParams.set('c1', normalizeColor(widgetValue));
+ const src = newURL.pathname + newURL.search;
+ await loadImage(src);
+ target.src = src;
+ if (!previewMode) {
+ this.previousSrc = src;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ switch (methodName) {
+ case 'color':
+ return new URL(this.$target[0].src, window.location.origin).searchParams.get('c1');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _computeVisibility(methodName, params) {
+ return this.$target.is("img[src^='/web_editor/shape/']");
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onImageChanged(methodName, params) {
+ return this.updateUI();
+ },
+});
return {
SnippetOptionWidget: SnippetOptionWidget,
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
index d405d647ce7..3134f55b373 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
@@ -10,6 +10,7 @@ var utils = require('web.utils');
var Widget = require('web.Widget');
var session = require('web.session');
const {removeOnImageChangeAttrs} = require('web_editor.image_processing');
+const {getCSSVariableValue, DEFAULT_PALETTE} = require('web_editor.utils');
var QWeb = core.qweb;
var _t = core._t;
@@ -106,6 +107,7 @@ var SearchableMediaWidget = MediaWidget.extend({
* @private
*/
_onSearchInput: function (ev) {
+ this.attachments = [];
this.search($(ev.currentTarget).val() || '').then(() => this._renderThumbnails());
this.hasSearched = true;
},
@@ -128,6 +130,7 @@ var FileWidget = SearchableMediaWidget.extend({
IMAGE_MIMETYPES: ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png', 'image/svg+xml'],
NUMBER_OF_ATTACHMENTS_TO_DISPLAY: 30,
+ MAX_DB_ATTACHMENTS: 5,
/**
* @constructor
@@ -144,6 +147,8 @@ var FileWidget = SearchableMediaWidget.extend({
this.attachments = [];
this.selectedAttachments = [];
+ this.libraryMedia = [];
+ this.selectedMedia = [];
this._onUploadURLButtonClick = dom.makeAsyncHandler(this._onUploadURLButtonClick);
},
@@ -204,7 +209,6 @@ var FileWidget = SearchableMediaWidget.extend({
* @override
*/
search: function (needle) {
- this.attachments = [];
this.needle = needle;
return this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, 0);
},
@@ -231,6 +235,12 @@ var FileWidget = SearchableMediaWidget.extend({
Array.prototype.splice.apply(this.attachments, [offset, attachments.length].concat(attachments));
});
},
+ /**
+ * Computes whether there is content to display in the template.
+ */
+ hasContent() {
+ return this.attachments.length;
+ },
//--------------------------------------------------------------------------
// Private
@@ -292,7 +302,7 @@ var FileWidget = SearchableMediaWidget.extend({
domain.push(['name', 'ilike', needle]);
}
domain.push('!', ['name', '=like', '%.crop']);
- domain.push('|', ['type', '=', 'binary'], ['url', '!=', false]);
+ domain.push('|', ['type', '=', 'binary'], '!', ['url', '=like', '/%/static/%']);
return domain;
},
/**
@@ -357,6 +367,7 @@ var FileWidget = SearchableMediaWidget.extend({
this._highlightSelected();
// adapt load more
+ this.$('.o_we_load_more').toggleClass('d-none', !this.hasContent());
var noLoadMoreButton = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY >= this.attachments.length;
var noMoreImgToLoad = this.numberOfAttachmentsToDisplay >= this.attachments.length;
this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad);
@@ -367,11 +378,36 @@ var FileWidget = SearchableMediaWidget.extend({
* @returns {Promise}
*/
_save: async function () {
+ // Create all media-library attachments.
+ const toSave = Object.fromEntries(this.selectedMedia.map(media => [
+ media.id, {
+ query: media.query || '',
+ is_dynamic_svg: !!media.isDynamicSVG,
+ }
+ ]));
+ let mediaAttachments = [];
+ if (Object.keys(toSave).length !== 0) {
+ mediaAttachments = await this._rpc({
+ route: '/web_editor/save_library_media',
+ params: {
+ media: toSave,
+ },
+ });
+ }
+ const selected = this.selectedAttachments.concat(mediaAttachments).map(attachment => {
+ // Color-customize dynamic SVGs with the primary theme color
+ if (attachment.image_src.startsWith('/web_editor/shape/')) {
+ const colorCustomizedURL = new URL(attachment.image_src, window.location.origin);
+ colorCustomizedURL.searchParams.set('c1', getCSSVariableValue('o-color-1'));
+ attachment.image_src = colorCustomizedURL.pathname + colorCustomizedURL.search;
+ }
+ return attachment;
+ });
if (this.options.multiImages) {
- return this.selectedAttachments;
+ return selected;
}
- var img = this.selectedAttachments[0];
+ const img = selected[0];
if (!img || !img.id) {
return this.media;
}
@@ -435,22 +471,30 @@ var FileWidget = SearchableMediaWidget.extend({
* and to close the media dialog
* @private
*/
- _selectAttachement: function (attachment, save) {
+ _selectAttachement: function (attachment, save, {type = 'attachment'} = {}) {
+ const possibleProps = {
+ 'attachment': 'selectedAttachments',
+ 'media': 'selectedMedia'
+ };
+ const prop = possibleProps[type];
if (this.options.multiImages) {
// if the clicked attachment is already selected then unselect it
// unless it was a save request (then keep the current selection)
- var index = this.selectedAttachments.indexOf(attachment);
+ const index = this[prop].indexOf(attachment);
if (index !== -1) {
if (!save) {
- this.selectedAttachments.splice(index, 1);
+ this[prop].splice(index, 1);
}
} else {
// if the clicked attachment is not selected, add it to selected
- this.selectedAttachments.push(attachment);
+ this[prop].push(attachment);
}
} else {
+ Object.values(possibleProps).forEach(prop => {
+ this[prop] = [];
+ });
// select the clicked attachment
- this.selectedAttachments = [attachment];
+ this[prop] = [attachment];
}
this._highlightSelected();
if (save) {
@@ -481,9 +525,15 @@ var FileWidget = SearchableMediaWidget.extend({
* @private
*/
_onAttachmentClick: function (ev) {
- var $attachment = $(ev.currentTarget);
- var attachment = _.find(this.attachments, {id: $attachment.data('id')});
- this._selectAttachement(attachment, !this.options.multiImages);
+ const attachment = ev.currentTarget;
+ const {id: attachmentID, mediaId} = attachment.dataset;
+ if (attachmentID) {
+ const attachment = this.attachments.find(attachment => attachment.id === parseInt(attachmentID));
+ this._selectAttachement(attachment, !this.options.multiImages);
+ } else if (mediaId) {
+ const media = this.libraryMedia.find(media => media.id === parseInt(mediaId));
+ this._selectAttachement(media, !this.options.multiImages, {type: 'media'});
+ }
},
/**
* Handles change of the file input: create attachments with the new files
@@ -639,6 +689,7 @@ var FileWidget = SearchableMediaWidget.extend({
* @override
*/
_onSearchInput: function () {
+ this.attachments = [];
this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY;
this._super.apply(this, arguments);
},
@@ -652,6 +703,7 @@ var ImageWidget = FileWidget.extend({
existingAttachmentsTemplate: 'wysiwyg.widgets.image.existing.attachments',
events: Object.assign({}, FileWidget.prototype.events, {
'change input.o_we_show_optimized': '_onShowOptimizedChange',
+ 'change .o_we_search_select': '_onSearchSelect',
}),
MIN_ROW_HEIGHT: 128,
@@ -659,6 +711,7 @@ var ImageWidget = FileWidget.extend({
* @constructor
*/
init: function (parent, media, options) {
+ this.searchService = 'all';
options = _.extend({
accept: 'image/*',
mimetypeDomain: [['mimetype', 'in', this.IMAGE_MIMETYPES]],
@@ -681,6 +734,60 @@ var ImageWidget = FileWidget.extend({
this.el.removeEventListener('load', this._onAttachmentImageLoad, true);
return this._super(...arguments);
},
+ /**
+ * @override
+ */
+ async fetchAttachments(number, offset) {
+ if (this.needle && this.searchService !== 'database') {
+ number = this.MAX_DB_ATTACHMENTS;
+ offset = 0;
+ }
+ const result = await this._super(number, offset);
+ // Color-substitution for dynamic SVG attachment
+ const primaryColor = getCSSVariableValue('o-color-1');
+ this.attachments.forEach(attachment => {
+ if (attachment.image_src.startsWith('/')) {
+ const newURL = new URL(attachment.image_src, window.location.origin);
+ // Set the main color of dynamic SVGs to o-color-1
+ if (attachment.image_src.startsWith('/web_editor/shape/')) {
+ newURL.searchParams.set('c1', primaryColor);
+ } else {
+ // Set height so that db images load faster
+ newURL.searchParams.set('height', 2 * this.MIN_ROW_HEIGHT);
+ }
+ attachment.thumbnail_src = newURL.pathname + newURL.search;
+ }
+ });
+ if (this.needle) {
+ try {
+ const response = await this._rpc({
+ route: '/web_editor/media_library_search',
+ params: {
+ 'query': this.needle,
+ 'offset': this.libraryMedia.length,
+ },
+ });
+ const newMedia = response.media;
+ this.nbMediaResults = response.results;
+ this.libraryMedia.push(...newMedia);
+ } catch (e) {
+ // Either API endpoint doesn't exist or is misconfigured.
+ console.error(`Couldn't reach API endpoint.`);
+ }
+ }
+ return result;
+ },
+ /**
+ * @override
+ */
+ hasContent() {
+ if (this.searchService === 'all') {
+ return this._super(...arguments) || this.libraryMedia.length;
+ } else if (this.searchService === 'media-library') {
+ return !!this.libraryMedia.length;
+ }
+ return this._super(...arguments);
+ },
//--------------------------------------------------------------------------
// Private
@@ -708,7 +815,7 @@ var ImageWidget = FileWidget.extend({
this.$('.o_existing_attachment_cell').addClass('d-none');
// Replace images that had been previously loaded if any to prevent scroll resetting to top
alreadyLoaded.each((index, el) => {
- const toReplace = this.$(`.o_existing_attachment_cell[data-id="${el.dataset.id}"]`);
+ const toReplace = this.$(`.o_existing_attachment_cell[data-id="${el.dataset.id}"], .o_existing_attachment_cell[data-media-id="${el.dataset.mediaId}"]`);
if (toReplace.length) {
toReplace.replaceWith(el);
}
@@ -720,13 +827,23 @@ var ImageWidget = FileWidget.extend({
flexGrow: placeholderWidth,
flexBasis: placeholderWidth,
});
+ if (this.needle && ['media-library', 'all'].includes(this.searchService)) {
+ const noMoreImgToLoad = this.libraryMedia.length === this.nbMediaResults;
+ const noLoadMoreButton = noMoreImgToLoad && this.libraryMedia.length <= 15;
+ this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad);
+ this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad);
+ }
},
/**
* @override
*/
_renderExisting: function (attachments) {
+ if (this.needle && this.searchService !== 'database') {
+ attachments = attachments.slice(0, this.MAX_DB_ATTACHMENTS);
+ }
return QWeb.render(this.existingAttachmentsTemplate, {
attachments: attachments,
+ libraryMedia: this.libraryMedia,
widget: this,
});
},
@@ -738,6 +855,16 @@ var ImageWidget = FileWidget.extend({
_toggleOptimized: function (value) {
this.$('.o_we_attachment_optimized').each((i, cell) => cell.style.setProperty('display', value ? null : 'none', 'important'));
},
+ /**
+ * @override
+ */
+ _highlightSelected: function () {
+ this._super(...arguments);
+ this.selectedMedia.forEach(media => {
+ this.$(`.o_existing_attachment_cell[data-media-id=${media.id}]`)
+ .addClass("o_we_attachment_selected");
+ });
+ },
//--------------------------------------------------------------------------
// Handlers
@@ -746,12 +873,37 @@ var ImageWidget = FileWidget.extend({
/**
* @override
*/
- _onAttachmentImageLoad: function (ev) {
+ _onAttachmentImageLoad: async function (ev) {
const img = ev.target;
const cell = img.closest('.o_existing_attachment_cell');
if (!cell) {
return;
}
+ if (cell.dataset.mediaId && !img.src.startsWith('blob')) {
+ const mediaUrl = img.src;
+ try {
+ const response = await fetch(mediaUrl);
+ if (response.headers.get('content-type') === 'image/svg+xml') {
+ const svg = await response.text();
+ const colorRegex = new RegExp(DEFAULT_PALETTE['1'], 'gi');
+ if (colorRegex.test(svg)) {
+ const fileName = mediaUrl.split('/').pop();
+ const file = new File([svg.replace(colorRegex, getCSSVariableValue('o-color-1'))], fileName, {
+ type: "image/svg+xml",
+ });
+ img.src = URL.createObjectURL(file);
+ const media = this.libraryMedia.find(media => media.id === parseInt(cell.dataset.mediaId));
+ if (media) {
+ media.isDynamicSVG = true;
+ }
+ // We changed the src: wait for the next load event to do the styling
+ return;
+ }
+ }
+ } catch (e) {
+ console.error('CORS is misconfigured on the API server, image will be treated as non-dynamic.');
+ }
+ }
const aspectRatio = img.naturalWidth / img.naturalHeight;
const width = aspectRatio * this.MIN_ROW_HEIGHT;
cell.style.flexGrow = width;
@@ -766,6 +918,21 @@ var ImageWidget = FileWidget.extend({
_onShowOptimizedChange: function (ev) {
this._toggleOptimized(ev.target.checked);
},
+ /**
+ * @override
+ */
+ _onSearchSelect: function (ev) {
+ const {value} = ev.target;
+ this.searchService = value;
+ this.$('.o_we_search').trigger('input');
+ },
+ /**
+ * @private
+ */
+ _onSearchInput: function (ev) {
+ this.libraryMedia = [];
+ this._super(...arguments);
+ },
});
diff --git a/addons/web_editor/static/src/scss/web_editor.common.scss b/addons/web_editor/static/src/scss/web_editor.common.scss
index 0cd22143290..ea7aee164e6 100644
--- a/addons/web_editor/static/src/scss/web_editor.common.scss
+++ b/addons/web_editor/static/src/scss/web_editor.common.scss
@@ -469,6 +469,31 @@ a.o_underline {
}
}
+.o_we_search_prompt {
+ position: relative;
+ min-height: 250px;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ & > h2 {
+ max-width: 500px;
+ text-align: center;
+ }
+
+ &::before {
+ content: "";
+ @include o-position-absolute($top: 0, $right: 50px);
+ width: 100px;
+ height: 150px;
+ opacity: .5;
+ background-image: url('/web_editor/static/src/img/curved_arrow.svg');
+ background-size: 100%;
+ background-repeat: no-repeat;
+ }
+}
+
@include media-breakpoint-down(sm) {
odoo-wysiwyg-container {
.panel-heading.note-toolbar {
diff --git a/addons/web_editor/static/src/scss/wysiwyg.scss b/addons/web_editor/static/src/scss/wysiwyg.scss
index 6eaa65c6e56..39fa326c737 100644
--- a/addons/web_editor/static/src/scss/wysiwyg.scss
+++ b/addons/web_editor/static/src/scss/wysiwyg.scss
@@ -394,6 +394,15 @@ img.o_we_selected_image {
img::selection {
background: transparent;
}
+.o_we_media_author {
+ font-size: 11px;
+ @include o-position-absolute($bottom: 0, $left: 0, $right: 0);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: center;
+ background-color: rgba(255, 255, 255, .7);
+}
@include media-breakpoint-down(md) {
#web_editor-top-edit {
diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml
index 714d0913e5d..96823fde263 100644
--- a/addons/web_editor/static/src/xml/wysiwyg.xml
+++ b/addons/web_editor/static/src/xml/wysiwyg.xml
@@ -125,7 +125,7 @@
<t t-call="wysiwyg.widgets.files.submenu"/>
<div class="form-text o_we_error_text"/>
<div class="o_we_existing_attachments"/>
- <div class="mt-4 text-center mx-auto">
+ <div class="mt-4 text-center mx-auto o_we_load_more">
<button class="btn btn-primary o_load_more d-none" type="button">Load more...</button>
<div class="mt-4 o_load_done_msg d-none">
<span><i>All images have been loaded</i></span>
@@ -170,12 +170,17 @@
<t t-set="searchPlaceholder">Search an image</t>
<t t-set="urlWarningTitle">The URL does not contain any image. The file will be added in the document section.</t>
<div class="d-flex justify-content-end flex-grow-1 pr-3">
- <div t-attf-class="custom-control custom-switch #{__debug__ or 'd-none'}">
+ <div t-attf-class="custom-control custom-switch #{__debug__ and 'd-flex' or 'd-none'} align-items-center mr-2">
<input class="o_we_show_optimized ml-2 custom-control-input" type="checkbox" id="o_we_show_optimized_switch"/>
<label class="custom-control-label" for="o_we_show_optimized_switch">
Show optimized images
</label>
</div>
+ <select class="custom-select o_we_search_select">
+ <option value="all">All</option>
+ <option value="database">My Images</option>
+ <option value="media-library">Illustrations</option>
+ </select>
</div>
</t>
</t>
@@ -288,14 +293,22 @@
<t t-name="wysiwyg.widgets.image.existing.attachments">
<div class="o_we_existing_attachments o_we_images d-flex flex-wrap w-100 justify-content-between align-items-stretch my-0">
- <div t-if="!attachments.length" class="o_nocontent_help">
- <p class="o_empty_folder_image">No images found.</p>
- <p class="o_empty_folder_subtitle">You can upload images with the button located in the top left of the screen.</p>
- </div>
+ <t t-if="!widget.hasContent()">
+ <div t-if="widget.needle" class="o_nocontent_help">
+ <p class="o_empty_folder_image">No images found.</p>
+ <p class="o_empty_folder_subtitle">You can upload images with the button located in the top left of the screen.</p>
+ </div>
+ <div t-else="" class="o_we_search_prompt">
+ <h2>Get the perfect image by searching in our library of copyright free photos and illustrations.</h2>
+ </div>
+ </t>
<t t-else="">
- <t t-foreach="attachments" t-as="attachment">
+ <t t-if="['all', 'database'].includes(widget.searchService)" t-foreach="attachments" t-as="attachment">
<t t-call="wysiwyg.widgets.image.existing.attachment"/>
</t>
+ <t t-if="['all', 'media-library'].includes(widget.searchService)" t-foreach="libraryMedia" t-as="media">
+ <t t-call="wysiwyg.widgets.image.library_media"/>
+ </t>
<!-- 20 placeholders is just enough for a 5K screen, change this if ImageWidget.MIN_ROW_HEIGHT changes -->
<t t-foreach="20">
<div class="o_we_attachment_placeholder"/>
@@ -309,13 +322,21 @@
<div t-attf-class="o_existing_attachment_cell position-relative bg-light #{isOptimized and 'o_we_attachment_optimized d-none' or ''} align-items-center justify-content-center" t-att-data-id="attachment.id">
<t t-call="wysiwyg.widgets.file.existing.remove"/>
<div class="o_we_media_dialog_img_wrapper">
- <!-- 256 is 2 * ImageWidget.MIN_ROW_HEIGHT, hopefully the highest display width of any image -->
- <img class="img img-fluid o_we_attachment_highlight" t-attf-src="#{attachment.image_src}?height=256" t-att-alt="attachment.name" t-att-title="attachment.name"/>
+ <img class="img img-fluid o_we_attachment_highlight" t-attf-src="#{attachment.thumbnail_src or attachment.image_src}" t-att-alt="attachment.name" t-att-title="attachment.name"/>
</div>
<span t-if="isOptimized" class="badge badge-success">Optimized</span>
</div>
</t>
+ <t t-name="wysiwyg.widgets.image.library_media">
+ <div t-attf-class="o_existing_attachment_cell position-relative bg-light align-items-center justify-content-center o_library_media_cell" t-att-data-media-id="media.id">
+ <div class="o_we_media_dialog_img_wrapper">
+ <img class="img img-fluid o_we_attachment_highlight" t-attf-src="#{media.thumbnail_url}" t-att-title="media.tooltip or ''" crossorigin="anonymous"/>
+ <a t-if="media.author" class="o_we_media_author" t-att-href="media.author_link" target="_blank" t-esc="media.author"/>
+ </div>
+ </div>
+ </t>
+
<t t-name="wysiwyg.widgets.document.existing.attachments">
<div class="o_we_existing_attachments o_we_documents">
<div t-if="!attachments.length" class="o_nocontent_help">
diff --git a/addons/web_editor/static/tests/field_html_tests.js b/addons/web_editor/static/tests/field_html_tests.js
index 21c54266fae..e24ae434907 100644
--- a/addons/web_editor/static/tests/field_html_tests.js
+++ b/addons/web_editor/static/tests/field_html_tests.js
@@ -261,12 +261,15 @@ QUnit.module('web_editor', {}, function () {
return Promise.resolve();
}
}
- if (route.indexOf('/web/static/src/img/transparent.png') === 0) {
+ if (route.indexOf('/web/image/123/transparent.png') === 0) {
return Promise.resolve();
}
if (route.indexOf('/web_unsplash/fetch_images') === 0) {
return Promise.resolve();
}
+ if (route.indexOf('/web_editor/media_library_search') === 0) {
+ return Promise.resolve();
+ }
return this._super(route, args);
},
});
@@ -289,10 +292,11 @@ QUnit.module('web_editor', {}, function () {
// load static xml file (dialog, media dialog, unsplash image widget)
await defMediaDialog;
+
await testUtils.dom.click($('.modal #editor-media-image .o_existing_attachment_cell:first').removeClass('d-none'));
var $editable = form.$('.oe_form_field[name="body"] .note-editable');
- assert.ok($editable.find('img')[0].dataset.src.includes('/web/static/src/img/transparent.png'),
+ assert.ok($editable.find('img')[0].dataset.src.includes('/web/image/123/transparent.png'),
"should have the image in the dom");
testUtils.mock.unpatch(MediaDialog);
diff --git a/addons/web_editor/static/tests/test_utils.js b/addons/web_editor/static/tests/test_utils.js
index 59b1c38e38b..1aa4a79c573 100644
--- a/addons/web_editor/static/tests/test_utils.js
+++ b/addons/web_editor/static/tests/test_utils.js
@@ -269,13 +269,13 @@ function wysiwygData(data) {
description: '',
mimetype: 'image/png',
checksum: false,
- url: '/web/static/src/img/transparent.png',
+ url: '/web/image/123/transparent.png',
type: 'url',
res_id: 0,
res_model: false,
public: true,
access_token: false,
- image_src: '/web/static/src/img/transparent.png',
+ image_src: '/web/image/123/transparent.png',
image_width: 256,
image_height: 256,
}],
diff --git a/addons/web_editor/views/snippets.xml b/addons/web_editor/views/snippets.xml
index 77118919f76..1dd5897f946 100644
--- a/addons/web_editor/views/snippets.xml
+++ b/addons/web_editor/views/snippets.xml
@@ -170,6 +170,7 @@
data-name="bg_image_opt"
data-dependencies="bg_image_opt"/>
</we-row>
+ <we-colorpicker string=" ⌙ Dynamic color" data-dynamic-color="true" data-name="dynamic_color_opt"/>
</div>
<div data-js="BackgroundPosition"
@@ -357,8 +358,12 @@
<div data-js="ImageOptimize"
data-selector="img">
- <t t-call="web_editor.snippet_options_image_optimization_widgets"/>
- </div>
+ <t t-call="web_editor.snippet_options_image_optimization_widgets"/>
+ </div>
+
+ <div data-js="DynamicSvg" data-selector="img">
+ <we-colorpicker string="Dynamic color" data-color="true"/>
+ </div>
</template>
<!-- default block -->
diff --git a/addons/web_unsplash/static/src/js/unsplash_image_widget.js b/addons/web_unsplash/static/src/js/unsplash_image_widget.js
index 229283b00c4..e638806dc1c 100644
--- a/addons/web_unsplash/static/src/js/unsplash_image_widget.js
+++ b/addons/web_unsplash/static/src/js/unsplash_image_widget.js
@@ -27,8 +27,6 @@ widgetsMedia.ImageWidget.include({
'click .o_unsplash_attachment_cell[data-imgid]': '_onUnsplashImgClick',
'click button.save_unsplash': '_onSaveUnsplashCredentials',
}),
- // Max number of database attachments to show when searching.
- maxDbAttachments: 5,
/**
* @override
@@ -103,10 +101,11 @@ widgetsMedia.ImageWidget.include({
*/
search: async function (needle) {
var self = this;
- await this._super(needle, true);
+ await this._super(...arguments);
this._unsplash.query = needle;
if (!needle) {
+ this._unsplash.records = [];
return;
}
@@ -118,6 +117,17 @@ widgetsMedia.ImageWidget.include({
self._unsplash.error = err;
});
},
+ /**
+ * @override
+ */
+ hasContent() {
+ if (this.searchService === 'all') {
+ return this._super(...arguments) || (this.unsplashRecords && this.unsplashRecords.length);
+ } else if (this.searchService === 'unsplash') {
+ return (this.unsplashRecords && this.unsplashRecords.length);
+ }
+ return this._super(...arguments);
+ },
//--------------------------------------------------------------------------
// Private
@@ -149,6 +159,10 @@ widgetsMedia.ImageWidget.include({
*/
_renderThumbnails: function () {
this._super(...arguments);
+ this.$('.unsplash_error').empty();
+ if (!['all', 'unsplash'].includes(this.searchService)) {
+ return;
+ }
if (this._unsplash.query && this._unsplash.error) {
this.$('.unsplash_error').html(
core.qweb.render('web_unsplash.dialog.error.content', {
@@ -157,9 +171,8 @@ widgetsMedia.ImageWidget.include({
);
return;
}
- this.$('.unsplash_error').empty();
- if (this._unsplash.query && !this._unsplash.isMaxed) {
+ if (['all', 'unsplash'].includes(this.searchService) && this._unsplash.query && !this._unsplash.isMaxed) {
this.$('.o_load_more').removeClass('d-none');
this.$('.o_load_done_msg').addClass('d-none');
}
@@ -168,10 +181,7 @@ widgetsMedia.ImageWidget.include({
* @override
*/
_renderExisting: function (attachments) {
- if (this._unsplash.records.length) {
- attachments.splice(this.maxDbAttachments);
- }
- return this._super(attachments.concat(this._unsplash.records.map(record => {
+ this.unsplashRecords = this._unsplash.records.map(record => {
const url = new URL(record.urls.regular);
// In small windows, row height could get quite a bit larger than the min, so we keep some leeway.
url.searchParams.set('h', 2 * this.MIN_ROW_HEIGHT);
@@ -179,7 +189,8 @@ widgetsMedia.ImageWidget.include({
return Object.assign({}, record, {
url: url.toString(),
});
- })));
+ });
+ return this._super(...arguments);
},
/**
* @override
diff --git a/addons/web_unsplash/static/src/scss/unsplash.scss b/addons/web_unsplash/static/src/scss/unsplash.scss
index 340b21581ec..9e0267c214d 100644
--- a/addons/web_unsplash/static/src/scss/unsplash.scss
+++ b/addons/web_unsplash/static/src/scss/unsplash.scss
@@ -1,14 +1,3 @@
-.o_we_unplash_author {
- font-size: 11px;
- bottom: 0;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- width: 100%;
- position: absolute;
- text-align: center;
- background-color: #FFFB;
-}
.unsplash_error {
padding: 30px 0;
.access_key_box {
diff --git a/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml b/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml
index b27a098c303..092f12038bd 100644
--- a/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml
+++ b/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml
@@ -2,25 +2,27 @@
<templates id="template" xml:space="preserve">
<t t-extend="wysiwyg.widgets.file">
- <t t-jquery=".o_we_existing_attachments" t-operation="after">
+ <t t-jquery=".o_we_load_more" t-operation="after">
<div class="unsplash_error"></div>
</t>
</t>
+<t t-extend="wysiwyg.widgets.image">
+ <t t-jquery="option[value='media-library']" t-operation="after">
+ <option value="unsplash">Photos (via Unsplash)</option>
+ </t>
+</t>
<t t-extend="wysiwyg.widgets.image.existing.attachments">
- <t t-jquery="t[t-call]" t-operation="replace">
- <t t-if="typeof(attachment.id) === 'number'">
- <t t-call="wysiwyg.widgets.image.existing.attachment"/>
- </t>
- <t t-else="">
+ <t t-jquery="t[t-foreach='libraryMedia']" t-operation="after">
+ <t t-if="['all', 'unsplash'].includes(widget.searchService)" t-foreach="widget.unsplashRecords" t-as="record">
<t t-call="web_unsplash.dialog.image.content"/>
</t>
</t>
</t>
<t t-name="web_unsplash.dialog.image.content">
- <div class="o_existing_attachment_cell o_unsplash_attachment_cell position-relative align-items-center justify-content-center bg-light" t-att-data-imgid="attachment.id" t-att-data-id="attachment.id" t-att-data-url="attachment.urls.regular" t-att-data-download-url="attachment.links.download_location" t-att-data-description="attachment.alt_description">
- <img class="img img-fluid o_we_attachment_highlight" t-att-src="attachment.url" t-att-alt="attachment.alt_description" style="max-height: 100%;"/>
- <a class="o_we_unplash_author" t-att-href="attachment.user.links.html" target="_blank" t-esc="attachment.user.name" t-att-title="attachment.user.name"/>
+ <div class="o_existing_attachment_cell o_unsplash_attachment_cell position-relative align-items-center justify-content-center bg-light" t-att-data-imgid="record.id" t-att-data-id="record.id" t-att-data-url="record.urls.regular" t-att-data-download-url="record.links.download_location" t-att-data-description="record.alt_description">
+ <img class="img img-fluid o_we_attachment_highlight" t-att-src="record.url" t-att-alt="record.alt_description" style="max-height: 100%;"/>
+ <a class="o_we_media_author" t-att-href="record.user.links.html" target="_blank" t-esc="record.user.name" t-att-title="record.user.name"/>
</div>
</t>
diff --git a/addons/website/static/src/js/widgets/media.js b/addons/website/static/src/js/widgets/media.js
new file mode 100644
index 00000000000..900f53068ba
--- /dev/null
+++ b/addons/website/static/src/js/widgets/media.js
@@ -0,0 +1,14 @@
+odoo.define('website.widgets.media', function (require) {
+'use strict';
+
+const {ImageWidget} = require('wysiwyg.widgets.media');
+
+ImageWidget.include({
+ _getAttachmentsDomain() {
+ const domain = this._super(...arguments);
+ domain.push('|', ['url', '=', false], '!', ['url', '=like', '/web/image/website.%']);
+ domain.push(['key', '=', false]);
+ return domain;
+ }
+});
+});
diff --git a/addons/website/views/assets.xml b/addons/website/views/assets.xml
index f59e696a9ef..0082f824c08 100644
--- a/addons/website/views/assets.xml
+++ b/addons/website/views/assets.xml
@@ -142,6 +142,8 @@
<script type="text/javascript" src="/website/static/src/js/editor/wysiwyg_multizone.js"/>
<script type="text/javascript" src="/website/static/src/js/editor/wysiwyg_multizone_translate.js"/>
<script type="text/javascript" src="/website/static/src/js/editor/widget_link.js"/>
+
+ <script type="text/javascript" src="/website/static/src/js/widgets/media.js"/>
</template>
<template id="website.assets_editor" name="Website Editor Assets (used in website editor)">
diff --git a/addons/website_blog/static/src/js/tours/website_blog.js b/addons/website_blog/static/src/js/tours/website_blog.js
index a2be1051ccf..6f071fe178f 100644
--- a/addons/website_blog/static/src/js/tours/website_blog.js
+++ b/addons/website_blog/static/src/js/tours/website_blog.js
@@ -32,8 +32,12 @@ odoo.define("website_blog.tour", function (require) {
content: _t("Set a blog post <b>cover</b>."),
position: "right",
}, {
- trigger: ".o_select_media_dialog .o_existing_attachment_cell:nth(1) img",
- extra_trigger: '.modal:has(.o_existing_attachment_cell:nth(1))',
+ trigger: ".o_select_media_dialog .o_we_search",
+ content: _t("Search for an image. (eg: type \"business\")"),
+ position: "top",
+ }, {
+ trigger: ".o_select_media_dialog .o_existing_attachment_cell:first img",
+ extra_trigger: '.modal:has(.o_existing_attachment_cell:first)',
content: _t("Choose an image from the library."),
position: "top",
}, {
diff --git a/addons/website_blog/tests/test_ui.py b/addons/website_blog/tests/test_ui.py
index 9a2f7aad09f..f4c7b003f8e 100644
--- a/addons/website_blog/tests/test_ui.py
+++ b/addons/website_blog/tests/test_ui.py
@@ -9,4 +9,11 @@ from odoo import tools
class TestUi(odoo.tests.HttpCase):
def test_admin(self):
self.env['blog.blog'].create({'name': 'Travel'})
+ self.env['ir.attachment'].create({
+ 'public': True,
+ 'type': 'url',
+ 'url': '/web/image/123/transparent.png',
+ 'name': 'transparent.png',
+ 'mimetype': 'image/png',
+ })
self.start_tour("/", 'blog', login='admin')