diff --git a/addons/mass_mailing/views/snippets_themes.xml b/addons/mass_mailing/views/snippets_themes.xml index 9633db6cfea..bb6ebc943d0 100644 --- a/addons/mass_mailing/views/snippets_themes.xml +++ b/addons/mass_mailing/views/snippets_themes.xml @@ -5,9 +5,9 @@ - - -
+ + +
-
+
@@ -797,12 +797,21 @@ data-exclude=".o_mail_no_options" data-drop-near=".col_mv, td, th"/> + .note-editable > div:not(.o_layout), .note-editable .oe_structure > div, .oe_snippet_body
+
+ +
+
diff --git a/addons/web_editor/models/ir_qweb.py b/addons/web_editor/models/ir_qweb.py index 78ebe9b2178..0cf3ff932c9 100644 --- a/addons/web_editor/models/ir_qweb.py +++ b/addons/web_editor/models/ir_qweb.py @@ -50,9 +50,10 @@ class QWeb(models.AbstractModel): view_id = View.get_view_id(el.attrib.get('t-call')) name = View.browse(view_id).name thumbnail = el.attrib.pop('t-thumbnail', "oe-thumbnail") - div = u'
' % ( + div = u'
' % ( escape(pycompat.to_text(name)), - escape(pycompat.to_text(thumbnail)) + escape(pycompat.to_text(thumbnail)), + escape(pycompat.to_text(view_id)), ) return [self._append(ast.Str(div))] + self._compile_node(el, options) + [self._append(ast.Str(u'
'))] diff --git a/addons/web_editor/models/ir_ui_view.py b/addons/web_editor/models/ir_ui_view.py index 0aefd108432..19470d40c3c 100644 --- a/addons/web_editor/models/ir_ui_view.py +++ b/addons/web_editor/models/ir_ui_view.py @@ -3,10 +3,11 @@ import copy import logging +import uuid from lxml import etree, html from odoo.exceptions import AccessError -from odoo import api, fields, models +from odoo import api, models _logger = logging.getLogger(__name__) @@ -271,3 +272,81 @@ class IrUiView(models.Model): View = self.with_context(active_test=False, lang=None) views = View._views_get(key, bundles=bundles) return views.filtered(lambda v: not v.groups_id or len(user_groups.intersection(v.groups_id))) + + # -------------------------------------------------------------------------- + # Snippet saving + # -------------------------------------------------------------------------- + + @api.model + def _get_default_snippet_thumbnail(self, snippet_class=None): + return '/web_editor/static/src/img/snippets_thumbs/s_custom_snippet.png' + + @api.model + def _get_snippet_addition_view_key(self, template_key, key): + return '%s.%s' % (template_key, key) + + @api.model + def _snippet_save_view_values_hook(self): + return {} + + @api.model + def save_snippet(self, name, arch, template_key, snippet_class=None, thumbnail_url=None): + """ + Saves a new snippet arch so that it appears with the given name when + using the given snippets template. + + :param name: the name of the snippet to save + :param arch: the html structure of the snippet to save + :param template_key: the key of the view regrouping all snippets in + which the snippet to save is meant to appear + :param snippet_class: a className which is supposed to uniquely-identify + the snippet from which the snippet to save originates + :param thumbnail_url: the url of the thumbnail to use when displaying + the snippet to save (default: see '_get_default_snippet_thumbnail') + """ + if not thumbnail_url: + thumbnail_url = self._get_default_snippet_thumbnail(snippet_class) + + app_name = template_key.split('.')[0] + snippet_class = snippet_class or 's_custom_snippet' + key = '%s_%s' % (snippet_class, uuid.uuid4().hex) + snippet_key = '%s.%s' % (app_name, key) + + # html to xml to add '/' at the end of self closing tags like br, ... + xml_arch = etree.tostring(html.fromstring(arch)) + new_snippet_view_values = { + 'name': name, + 'key': snippet_key, + 'type': 'qweb', + 'arch': xml_arch, + } + new_snippet_view_values.update(self._snippet_save_view_values_hook()) + self.create(new_snippet_view_values) + + custom_section = self.search([('key', '=', template_key)]) + snippet_addition_view_values = { + 'name': name + ' Block', + 'key': self._get_snippet_addition_view_key(template_key, key), + 'inherit_id': custom_section.id, + 'type': 'qweb', + 'arch': """ + + + + + + + + + """ % (template_key, snippet_key, thumbnail_url), + } + snippet_addition_view_values.update(self._snippet_save_view_values_hook()) + self.create(snippet_addition_view_values) + + @api.model + def delete_snippet(self, view_id, template_key): + snippet_view = self.browse(view_id) + key = snippet_view.key.split('.')[1] + custom_key = self._get_snippet_addition_view_key(template_key, key) + snippet_addition_view = self.search([('key', '=', custom_key)]) + (snippet_addition_view | snippet_view).unlink() diff --git a/addons/web_editor/static/src/img/snippets_thumbs/s_custom_snippet.png b/addons/web_editor/static/src/img/snippets_thumbs/s_custom_snippet.png new file mode 100644 index 00000000000..76ffb0905fb Binary files /dev/null and b/addons/web_editor/static/src/img/snippets_thumbs/s_custom_snippet.png differ 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 1e510ba9414..57ab6c37b73 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -780,6 +780,7 @@ var SnippetsMenu = Widget.extend({ events: { 'click .o_install_btn': '_onInstallBtnClick', 'click .o_we_invisible_entry': '_onInvisibleEntryClick', + 'click #snippet_custom .o_delete_btn': '_onDeleteBtnClick', }, custom_events: { 'activate_insertion_zones': '_onActivateInsertionZones', @@ -801,6 +802,7 @@ var SnippetsMenu = Widget.extend({ 'block_preview_overlays': '_onBlockPreviewOverlays', 'unblock_preview_overlays': '_onUnblockPreviewOverlays', 'user_value_widget_opening': '_onUserValueWidgetOpening', + 'reload_snippet_template': '_onReloadSnippetTemplate', }, /** @@ -857,26 +859,22 @@ var SnippetsMenu = Widget.extend({ this.window = this.ownerDocument.defaultView; this.$window = $(this.window); + this.customizePanel = document.createElement('div'); + this.customizePanel.classList.add('o_we_customize_panel', 'd-none'); + + this.invisibleDOMPanelEl = document.createElement('div'); + this.invisibleDOMPanelEl.classList.add('o_we_invisible_el_panel'); + this.invisibleDOMPanelEl.appendChild( + $('
', { + text: _t('Invisible Elements'), + class: 'o_panel_header', + }).prepend( + $('', {class: 'fa fa-eye-slash'}) + )[0] + ); + // Fetch snippet templates and compute it - - defs.push(this.loadSnippets().then(html => { - return this._computeSnippetTemplates(html); - }).then(() => { - this.customizePanel = document.createElement('div'); - this.customizePanel.classList.add('o_we_customize_panel', 'd-none'); - this.$el.append(this.customizePanel); - - this.invisibleDOMPanelEl = document.createElement('div'); - this.invisibleDOMPanelEl.classList.add('o_we_invisible_el_panel'); - this.invisibleDOMPanelEl.appendChild( - $('
', { - text: _t('Invisible Elements'), - class: 'o_panel_header', - }).prepend( - $('', {class: 'fa fa-eye-slash'}) - )[0] - ); - this.$el.append(this.invisibleDOMPanelEl); + defs.push(this._loadSnippetsTemplates().then(() => { return this._updateInvisibleDOM(); })); @@ -998,9 +996,10 @@ var SnippetsMenu = Widget.extend({ }, /** * Load snippets. + * @param {boolean} invalidateCache */ - loadSnippets: function () { - if (this.cacheSnippetTemplate[this.options.snippets]) { + loadSnippets: function (invalidateCache) { + if (!invalidateCache && this.cacheSnippetTemplate[this.options.snippets]) { this._defLoadSnippets = this.cacheSnippetTemplate[this.options.snippets]; return this._defLoadSnippets; } @@ -1285,6 +1284,17 @@ var SnippetsMenu = Widget.extend({ }); }); }, + /** + * @private + * @param {boolean} invalidateCache + */ + _loadSnippetsTemplates: async function (invalidateCache) { + return this._mutex.exec(async () => { + await this._destroyEditors(); + const html = await this.loadSnippets(invalidateCache); + await this._computeSnippetTemplates(html); + }); + }, /** * @private */ @@ -1502,6 +1512,7 @@ var SnippetsMenu = Widget.extend({ var $snippet = $(this); var name = $snippet.attr('name'); var $sbody = $snippet.children(':not(.oe_snippet_thumbnail)').addClass('oe_snippet_body'); + const isCustomSnippet = !!$snippet.parents('#snippet_custom').length; // Associate in-page snippets to their name if ($sbody.length) { @@ -1525,6 +1536,13 @@ var SnippetsMenu = Widget.extend({ $snippet.find('[data-oe-thumbnail]').data('oeThumbnail'), name )); + if (isCustomSnippet) { + const btn = document.createElement('we-button'); + btn.dataset.snippetId = $snippet.data('oeSnippetId'); + btn.classList.add('o_delete_btn', 'fa', 'fa-trash'); + $thumbnail.prepend(btn); + $thumbnail.prepend($('
')); + } $snippet.prepend($thumbnail); // Create the install button (t-install feature) if necessary @@ -1563,6 +1581,8 @@ var SnippetsMenu = Widget.extend({ // Add the computed template and make elements draggable this.$el.html($html); + this.$el.append(this.customizePanel); + this.$el.append(this.invisibleDOMPanelEl); this._makeSnippetDraggable(this.$snippets); this._disableUndroppableSnippets(); @@ -1677,7 +1697,11 @@ var SnippetsMenu = Widget.extend({ $snippets.draggable({ greedy: true, - helper: 'clone', + helper: function () { + const dragSnip = this.cloneNode(true); + dragSnip.querySelectorAll('.o_delete_btn, .o_image_ribbon').forEach(el => el.remove()); + return dragSnip; + }, appendTo: this.$body, cursor: 'move', handle: '.oe_snippet_thumbnail', @@ -1971,6 +1995,43 @@ var SnippetsMenu = Widget.extend({ .toggleClass('fa-eye-slash', !isVisible); return this._activateSnippet(isVisible ? $snippet : false); }, + /** + * @private + */ + _onDeleteBtnClick: function (ev) { + const $snippet = $(ev.target).closest('.oe_snippet'); + new Dialog(this, { + size: 'medium', + title: _t('Confirmation'), + $content: $('

' + _t(`Are you sure you want to delete the snippet: ${$snippet.attr('name')} ?`) + '

'), + buttons: [{ + text: _t("Yes"), + close: true, + classes: 'btn-primary', + click: async () => { + await this._rpc({ + model: 'ir.ui.view', + method: 'delete_snippet', + kwargs: { + 'view_id': parseInt(ev.currentTarget.dataset.snippetId), + 'template_key': this.options.snippets, + }, + }); + await this._loadSnippetsTemplates(true); + }, + }, { + text: _t("No"), + close: true, + }], + }).open(); + }, + /** + * @private + */ + _onReloadSnippetTemplate: async function (ev) { + await this._activateSnippet(false); + await this._loadSnippetsTemplates(true); + }, /** * @private */ 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 86a3f70ac2b..53a36008a56 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -3,6 +3,7 @@ odoo.define('web_editor.snippets.options', function (require) { var core = require('web.core'); const ColorpickerDialog = require('web.ColorpickerDialog'); +const Dialog = require('web.Dialog'); const time = require('web.time'); var Widget = require('web.Widget'); var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget; @@ -2973,6 +2974,60 @@ registry.many2one = SnippetOptionWidget.extend({ } }); +/** + * Handle the save of a snippet as a template that can be reused later + */ +registry.SnippetSave = SnippetOptionWidget.extend({ + xmlDependencies: ['/web_editor/static/src/xml/editor.xml'], + isTopOption: true, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + saveSnippet: function (previewMode, widgetValue, params) { + return new Promise(resolve => { + const dialog = new Dialog(this, { + title: _t("Save Your Block"), + size: 'small', + $content: $(qweb.render('web_editor.dialog.save_snippet', { + currentSnippetName: _.str.sprintf(_t("Custom %s"), this.data.snippetName), + })), + buttons: [{ + text: _t("Save"), + classes: 'btn-primary', + close: true, + click: async () => { + const snippetName = dialog.el.querySelector('.o_we_snippet_name_input').value; + const targetCopyEl = this.$target[0].cloneNode(true); + delete targetCopyEl.dataset.name; + await this._rpc({ + model: 'ir.ui.view', + method: 'save_snippet', + kwargs: { + 'name': snippetName, + 'arch': targetCopyEl.outerHTML, + 'template_key': this.options.snippets, + 'snippet_class': [...this.$target[0].classList].filter(x => /\bs_./g.test(x))[0], + }, + }); + this.trigger_up('reload_snippet_template'); + resolve(); + }, + }, { + text: _t("Discard"), + close: true, + click: () => resolve(), + }], + }).open(); + }); + }, +}); + + return { SnippetOptionWidget: SnippetOptionWidget, snippetOptionRegistry: registry, diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss index 3dcb6e872e9..b8e87ff69f5 100644 --- a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss +++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss @@ -171,6 +171,33 @@ body.editor_enable.editor_has_snippets { } } } + #snippet_custom .oe_snippet .oe_snippet_thumbnail { + .o_delete_btn { + @include o-position-absolute(0, 0); + z-index: 1; + display: none; + width: 25px; + height: 25px; + background-color: rgba(255, 0, 0, 0.75); + justify-content: center; + align-items: center; + + &:hover { + cursor: pointer; + } + } + &:hover .o_delete_btn { + display: flex; + } + .o_image_ribbon { + position: absolute; + z-index: 1; + width: 0; + height: 0; + border-right: 20px solid transparent; + border-top: 20px solid $o-enterprise-color; + } + } } > .o_we_customize_panel { diff --git a/addons/web_editor/static/src/xml/editor.xml b/addons/web_editor/static/src/xml/editor.xml index b1d7128e2c1..bb4abe15f60 100644 --- a/addons/web_editor/static/src/xml/editor.xml +++ b/addons/web_editor/static/src/xml/editor.xml @@ -25,4 +25,18 @@
+ + +
+
+ +
+ +
+
+
+
diff --git a/addons/web_editor/views/snippets.xml b/addons/web_editor/views/snippets.xml index 8f44a8f05a4..ab920aa65fb 100644 --- a/addons/web_editor/views/snippets.xml +++ b/addons/web_editor/views/snippets.xml @@ -14,14 +14,22 @@
-
+
- First Panel -
-
- + Custom
+
+ +
+
+ First Panel +
+
+ +
+
+
diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index 3618c0a141c..881b8355a3e 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -2,11 +2,13 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging -import werkzeug +import os import uuid +import werkzeug from odoo import api, fields, models from odoo import tools +from odoo.addons import website from odoo.addons.http_routing.models.ir_http import url_for from odoo.osv import expression from odoo.http import request @@ -462,3 +464,23 @@ class View(models.Model): if website_specific_view: self = website_specific_view super(View, self).save(value, xpath=xpath) + + # -------------------------------------------------------------------------- + # Snippet saving + # -------------------------------------------------------------------------- + + @api.model + def _get_default_snippet_thumbnail(self, snippet_class=None): + if snippet_class: + for path in website.__path__: + if os.path.isfile(path + '/static/src/img/snippets_thumbs/%s.png' % snippet_class): + return '/website/static/src/img/snippets_thumbs/%s.png' % snippet_class + return super()._get_default_snippet_thumbnail() + + @api.model + def _snippet_save_view_values_hook(self): + res = super()._snippet_save_view_values_hook() + website_id = self.env.context.get('website_id') + if website_id: + res['website_id'] = website_id + return res diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index 9024cf7b8e0..5468ab9f321 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -3,7 +3,6 @@ odoo.define('website.editor.snippets.options', function (require) { var core = require('web.core'); var Dialog = require('web.Dialog'); -var weWidgets = require('wysiwyg.widgets'); const wUtils = require('website.utils'); var options = require('web_editor.snippets.options'); diff --git a/addons/website/views/snippets/s_title.xml b/addons/website/views/snippets/s_title.xml index 5fdbb9975af..db2791be3c6 100644 --- a/addons/website/views/snippets/s_title.xml +++ b/addons/website/views/snippets/s_title.xml @@ -2,7 +2,7 @@