[IMP] web_editor, *: allow saving of snippets

* website, mass_mailing, website_event, website_form,
  website_mass_mailing, website_sale

Now the user can save snippets to use them on other pages.

task-2120409

closes odoo/odoo#40408

Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
Co-authored-by: qsm-odoo <qsm@odoo.com>
This commit is contained in:
fja-odoo
2020-01-16 14:29:04 +00:00
committed by qsm-odoo
co-authored by qsm-odoo
parent 4946739c17
commit 218e1cad61
17 changed files with 333 additions and 58 deletions
+14 -5
View File
@@ -5,9 +5,9 @@
<xpath expr="//div[@id='snippets_menu']" position="inside">
<button type="button" disabled="disabled">Select a template</button>
</xpath>
<xpath expr="//div[@id='o_scroll']" position="replace">
<t t-set="company_id" t-value="res_company"/>
<div id="o_scroll">
<xpath expr="//t[@id='default_snippets']" position="replace">
<t id="default_snippets">
<t t-set="company_id" t-value="res_company"/>
<div id="email_designer_themes">
<div data-name="basic"
data-nowrap="1"
@@ -70,7 +70,7 @@
<t t-snippet="mass_mailing.s_mail_block_footer_social_left" t-thumbnail="/mass_mailing/static/src/img/blocks/block_footer_social_left.png"/>
</div>
</div>
</div>
</t>
</xpath>
<xpath expr="//div[@id='snippet_options']" position="inside">
<t t-call="mass_mailing.snippet_options"/>
@@ -797,12 +797,21 @@
data-exclude=".o_mail_no_options"
data-drop-near=".col_mv, td, th"/>
<t t-set="mailing_content_selector">.note-editable > div:not(.o_layout), .note-editable .oe_structure > div, .oe_snippet_body</t>
<div data-js="content"
data-selector=".note-editable > div:not(.o_layout), .note-editable .oe_structure > div, .oe_snippet_body"
t-att-data-selector="mailing_content_selector"
data-exclude=".o_mail_no_options"
data-drop-near="[data-oe-field='body_html']:not(:has(.o_layout)) > *, .oe_structure > *"
data-drop-in="[data-oe-field='body_html']:not(:has(.o_layout)), .oe_structure"/>
<div data-js="SnippetSave"
t-att-data-selector="mailing_content_selector">
<we-button class="fa fa-fw fa-save"
title="Save the block to use it elsewhere"
data-save-snippet=""
data-no-preview="true"/>
</div>
<div data-js="sizing_y"
data-selector=".note-editable > div:not(.o_layout), .note-editable .oe_structure > div, td, th"
data-exclude=".o_mail_no_resize, .o_mail_no_options"/>
+3 -2
View File
@@ -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 name="%s" data-oe-type="snippet" data-oe-thumbnail="%s">' % (
div = u'<div name="%s" data-oe-type="snippet" data-oe-thumbnail="%s" data-oe-snippet-id="%s">' % (
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'</div>'))]
+80 -1
View File
@@ -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': """
<data inherit_id="%s">
<xpath expr="//div[@id='snippet_custom']" position="attributes">
<attribute name="class" remove="d-none" separator=" "/>
</xpath>
<xpath expr="//div[@id='snippet_custom_body']" position="inside">
<t t-snippet="%s" t-thumbnail="%s"/>
</xpath>
</data>
""" % (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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 977 B

@@ -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(
$('<div/>', {
text: _t('Invisible Elements'),
class: 'o_panel_header',
}).prepend(
$('<i/>', {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(
$('<div/>', {
text: _t('Invisible Elements'),
class: 'o_panel_header',
}).prepend(
$('<i/>', {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($('<div class="o_image_ribbon"/>'));
}
$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: $('<div><p>' + _t(`Are you sure you want to delete the snippet: ${$snippet.attr('name')} ?`) + '</p></div>'),
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
*/
@@ -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,
@@ -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 {
@@ -25,4 +25,18 @@
</div>
</div>
</t>
<!-- Save Snippet Name option dialog -->
<t t-name="web_editor.dialog.save_snippet">
<div>
<div class="form-group form-row">
<label class="col-form-label col-md-3" for="snippetName">Name</label>
<div class="col-md-9">
<input type="text"
class="form-control o_we_snippet_name_input"
id="snippetName"
t-att-value="currentSnippetName"/>
</div>
</div>
</div>
</t>
</templates>
+13 -5
View File
@@ -14,14 +14,22 @@
</div>
<div id="o_scroll">
<div id="snippet_structure" class="o_panel">
<div id="snippet_custom" class="o_panel d-none">
<div class="o_panel_header">
<i class="fa fa-th-large"/> First Panel
</div>
<div class="o_panel_body">
<t t-snippet="web_editor.s_hr" t-thumbnail="/website/static/src/img/snippets_thumbs/s_separator.png"/>
<i class="fa fa-fw fa-save"/> Custom
</div>
<div id="snippet_custom_body" class="o_panel_body"/>
</div>
<t id="default_snippets">
<div id="snippet_structure" class="o_panel">
<div class="o_panel_header">
<i class="fa fa-th-large"/> First Panel
</div>
<div class="o_panel_body">
<t t-snippet="web_editor.s_hr" t-thumbnail="/website/static/src/img/snippets_thumbs/s_separator.png"/>
</div>
</div>
</t>
</div>
<div id="snippet_options" class="d-none">
+23 -1
View File
@@ -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
@@ -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');
+1 -1
View File
@@ -2,7 +2,7 @@
<odoo>
<template id="s_title" name="Title">
<section class="pt32 pb32">
<section class="s_title pt32 pb32">
<div class="container">
<div class="row s_nb_column_fixed">
<div class="col-lg-12 s_title pt16 pb16" style="text-align: center;">
+16 -7
View File
@@ -3,8 +3,8 @@
<!-- Snippets menu -->
<template id="snippets" inherit_id="web_editor.snippets" primary="True">
<xpath expr="//div[@id='o_scroll']" position="replace">
<div id="o_scroll">
<xpath expr="//t[@id='default_snippets']" position="replace">
<t id="default_snippets">
<div id="snippet_mega_menu" class="o_panel d-none">
<div class="o_panel_header">
<i class="fa fa-puzzle-piece"/> Mega Menu
@@ -77,7 +77,7 @@
<t t-snippet="website.s_chart" t-thumbnail="/website/static/src/img/snippets_thumbs/s_chart.png"/>
</div>
</div>
</div>
</t>
</xpath>
<xpath expr="//div[@id='snippet_options']/t" position="attributes">
@@ -308,16 +308,25 @@
data-drop-near=".row > div"
data-exclude=".s_col_no_resize.row > div"/>
<t t-set="so_snippet_addition_selector">section, .parallax, .s_popup</t>
<div id="so_snippet_addition"
data-selector="section, .parallax, .s_popup"
t-att-data-selector="so_snippet_addition_selector"
data-drop-in=":not(p).oe_structure:not(.oe_structure_solo), :not(p)[data-oe-type=html], :not(p).oe_structure.oe_structure_solo:not(:has(> section, > div))"/>
<!-- Main content drop -->
<t t-set="so_content_addition_selector">blockquote, .s_btn, .s_card, .s_alert, .o_facebook_page, .s_share, .s_rating, .s_hr, .s_countdown, .s_chart</t>
<div id="so_content_addition"
data-selector="blockquote, .s_btn, .s_card, .s_alert, .o_facebook_page, .s_share, .s_rating, .s_hr, .s_countdown, .s_chart"
data-drop-near="p, h1, h2, h3, blockquote, .s_btn, .s_card, .s_alert, .o_facebook_page, .s_share, .s_rating, .s_hr, .s_chart"
t-att-data-selector="so_content_addition_selector"
t-attf-data-drop-near="p, h1, h2, h3, #{so_content_addition_selector}"
data-drop-in=".content, nav"/>
<div data-js="SnippetSave"
t-attf-data-selector="#{so_snippet_addition_selector}, #{so_content_addition_selector}">
<we-button class="fa fa-fw fa-save"
title="Save the block to use it elsewhere"
data-save-snippet=""
data-no-preview="true"/>
</div>
<div data-js="ul"
data-selector=":not(li) > ul:has(ul,ol), :not(li) > ol:has(ul,ol)">
<we-checkbox string="Folded list" data-select-class="o_ul_folded"/>
@@ -59,9 +59,7 @@
</template>
<template id="snippet_options" inherit_id="website.snippet_options">
<xpath expr="//div[@id='so_content_addition']" position="attributes">
<attribute name="data-selector" add=".oe_country_events, .s_speaker_bio" separator=","/>
</xpath>
<xpath expr="//*[@t-set='so_content_addition_selector']" position="inside">, .oe_country_events, .s_speaker_bio</xpath>
</template>
<!-- Snippet - Speaker Bio -->
+1 -3
View File
@@ -24,9 +24,7 @@
</template>
<template id="snippet_options" inherit_id="website.snippet_options">
<xpath expr="//*[@id='so_snippet_addition']" position="attributes">
<attribute name="data-selector" add=".s_website_form" separator=","/>
</xpath>
<xpath expr="//*[@t-set='so_snippet_addition_selector']" position="inside">, .s_website_form</xpath>
<xpath expr="//div" position="after">
<!-- Form -->
<div data-js="website_form_editor" data-selector=".s_website_form">
@@ -55,9 +55,7 @@
</template>
<template id="newsletter_subscribe_options" name="Newsletter Subscribe Options" inherit_id="website.snippet_options">
<xpath expr="//*[@id='so_snippet_addition']" position="attributes">
<attribute name="data-selector" add=".o_newsletter_popup" separator=","/>
</xpath>
<xpath expr="//*[@t-set='so_snippet_addition_selector']" position="inside">, .o_newsletter_popup</xpath>
<xpath expr="//*[@id='row_valign_snippet_option']" position="attributes">
<attribute name="data-selector" separator="," add=".s_newsletter_block"/>
</xpath>
+1 -4
View File
@@ -106,10 +106,7 @@
<we-button data-no-preview="true" data-open-searchbar-settings="">Products Search Bar</we-button>
</div>
</xpath>
<xpath expr="//div[@id='so_content_addition']" position="attributes">
<attribute name="data-selector" add=".s_wsale_products_searchbar_input" separator=","/>
<attribute name="data-drop-near" add=".s_wsale_products_searchbar_input" separator=","/>
</xpath>
<xpath expr="//*[@t-set='so_content_addition_selector']" position="inside">, .s_wsale_products_searchbar_input</xpath>
</template>
</odoo>