[IMP] web_editor, test_website: allow renaming custom snippet blocks

When you save your custom snippet you probably won't give it a
specific name.

Before this commit several custom snippets name was specified on save
and there was no way to modify their name afterwards

After this commit custom snippets are assigned a generated name but they
can be renamed afterwards

task-2374802

closes odoo/odoo#61483

Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
This commit is contained in:
Benoit Socias
2021-02-03 15:13:19 +00:00
parent 52b87b6948
commit 1044a76a19
7 changed files with 230 additions and 131 deletions
@@ -10,12 +10,13 @@ var tour = require('web_tour.tour');
* -> drag a banner into page content
* -> customize banner (set text)
* -> save banner as custom snippet
* -> confirm name (TODO: remove when implicit default name feature is implemented)
* -> go to blocks
* -> confirm save
* -> ensure custom snippet is available
* -> drag custom snippet
* -> ensure block appears as banner
* -> ensure block appears as custom banner
* -> rename custom banner
* -> verify rename took effect
* -> delete custom snippet
* -> confirm delete
* -> ensure it was deleted
@@ -46,26 +47,35 @@ tour.register('test_custom_snippet', {
content: "save custom snippet",
trigger: ".snippet-option-SnippetSave we-button",
},
{
content: "confirm save name",
trigger: ".modal-dialog button span:contains('Save')",
},
{
content: "confirm reload",
trigger: ".modal-dialog button span:contains('Save and Reload')",
},
{
content: "go to blocks",
trigger: ".o_we_add_snippet_btn",
},
{
content: "ensure custom snippet appeared",
trigger: "#oe_snippets .oe_snippet[name='Custom Banner']",
run: function() {}, // check
run: function () {
$("#oe_snippets .oe_snippet[name='Custom Banner'] .o_rename_btn").attr("style", "display: block;");
// hover is needed for rename button to appear
},
},
{
content: "rename custom snippet",
trigger: ".oe_snippet[name='Custom Banner'] we-button.o_rename_btn",
extra_trigger: ".oe_snippet[name='Custom Banner'] .oe_snippet_thumbnail:not(.o_we_already_dragging)",
},
{
content: "set name",
trigger: ".oe_snippet[name='Custom Banner'] input",
run: "text Bruce Banner",
},
{
content: "confirm rename",
trigger: ".oe_snippet[name='Custom Banner'] we-button.o_we_confirm_btn",
},
{
content: "drop custom snippet",
trigger: ".oe_snippet[name='Custom Banner'] .oe_snippet_thumbnail:not(.o_we_already_dragging)",
trigger: ".oe_snippet[name='Bruce Banner'] .oe_snippet_thumbnail:not(.o_we_already_dragging)",
extra_trigger: "body.editor_enable.editor_has_snippets",
moveTrigger: ".oe_drop_zone",
run: "drag_and_drop #wrap",
@@ -73,17 +83,20 @@ tour.register('test_custom_snippet', {
{
content: "ensure banner section exists",
trigger: "#wrap section[data-name='Banner']",
run: function() {}, // check
run: function () {}, // check
},
{
content: "ensure custom banner section exists",
trigger: "#wrap section[data-name='Custom Banner']",
run: function() {}, // check
trigger: "#wrap section[data-name='Bruce Banner']",
run: function () {
$("#oe_snippets .oe_snippet[name='Bruce Banner'] .o_delete_btn").attr("style", "display: block;");
// hover is needed for delete button to appear
},
},
{
content: "delete custom snippet",
trigger: ".oe_snippet[name='Custom Banner'] we-button.o_delete_btn",
extra_trigger: ".oe_snippet[name='Custom Banner'] .oe_snippet_thumbnail:not(.o_we_already_dragging)",
trigger: ".oe_snippet[name='Bruce Banner'] we-button.o_delete_btn",
extra_trigger: ".oe_snippet[name='Bruce Banner'] .oe_snippet_thumbnail:not(.o_we_already_dragging)",
},
{
content: "confirm delete",
@@ -92,8 +105,8 @@ tour.register('test_custom_snippet', {
{
content: "ensure custom snippet disappeared",
trigger: "#oe_snippets",
extra_trigger: "#oe_snippets:not(:has(.oe_snippet[name='Custom Banner']))",
run: function() {}, // check
extra_trigger: "#oe_snippets:not(:has(.oe_snippet[name='Bruce Banner']))",
run: function () {}, // check
},
]);
+27
View File
@@ -8,6 +8,7 @@ from lxml import etree, html
from odoo.exceptions import AccessError
from odoo import api, models
from odoo.osv import expression
_logger = logging.getLogger(__name__)
@@ -298,6 +299,14 @@ class IrUiView(models.Model):
def _snippet_save_view_values_hook(self):
return {}
def _find_available_name(self, name, used_names):
attempt = 1
candidate_name = name
while candidate_name in used_names:
attempt += 1
candidate_name = f"{name} ({attempt})"
return candidate_name
@api.model
def save_snippet(self, name, arch, template_key, snippet_key, thumbnail_url):
"""
@@ -317,6 +326,14 @@ class IrUiView(models.Model):
snippet_key = '%s_%s' % (snippet_key, uuid.uuid4().hex)
full_snippet_key = '%s.%s' % (app_name, snippet_key)
# find available name
current_website = self.env['website'].browse(self._context.get('website_id'))
website_domain = current_website.website_domain()
used_names = self.search(expression.AND([
[('name', '=like', '%s%%' % name)], website_domain
])).mapped('name')
name = self._find_available_name(name, used_names)
# 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 = {
@@ -348,6 +365,16 @@ class IrUiView(models.Model):
snippet_addition_view_values.update(self._snippet_save_view_values_hook())
self.create(snippet_addition_view_values)
@api.model
def rename_snippet(self, name, 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)])
if snippet_addition_view:
snippet_addition_view.name = name + ' Block'
snippet_view.name = name
@api.model
def delete_snippet(self, view_id, template_key):
snippet_view = self.browse(view_id)
@@ -894,6 +894,7 @@ var SnippetsMenu = Widget.extend({
'click .o_install_btn': '_onInstallBtnClick',
'click .o_we_add_snippet_btn': '_onBlocksTabClick',
'click .o_we_invisible_entry': '_onInvisibleEntryClick',
'click #snippet_custom .o_rename_btn': '_onRenameBtnClick',
'click #snippet_custom .o_delete_btn': '_onDeleteBtnClick',
'mousedown': '_onMouseDown',
'input .o_snippet_search_filter_input': '_onSnippetSearchInput',
@@ -1760,8 +1761,13 @@ var SnippetsMenu = Widget.extend({
}));
}
// Create the delete button for custom snippets
// Create the rename and delete button for custom snippets
if (isCustomSnippet) {
const btnRenameEl = document.createElement('we-button');
btnRenameEl.dataset.snippetId = $snippet.data('oeSnippetId');
btnRenameEl.classList.add('o_rename_btn', 'fa', 'fa-pencil', 'btn', 'o_we_hover_success');
btnRenameEl.title = _.str.sprintf(_t("Rename %s"), name);
$snippet.append(btnRenameEl);
const btnEl = document.createElement('we-button');
btnEl.dataset.snippetId = $snippet.data('oeSnippetId');
btnEl.classList.add('o_delete_btn', 'fa', 'fa-trash', 'btn', 'o_we_hover_danger');
@@ -2438,6 +2444,50 @@ var SnippetsMenu = Widget.extend({
}],
}).open();
},
/**
* @private
*/
_onRenameBtnClick: function (ev) {
const $snippet = $(ev.target).closest('.oe_snippet');
const snippetName = $snippet.attr('name');
const confirmText = _t('Confirm');
const cancelText = _t('Cancel');
const $input = $(`
<we-input class="o_we_user_value_widget w-100 mx-1">
<div>
<input type="text" autocomplete="chrome-off" value="${snippetName}" class="text-left"/>
<we-button class="o_we_confirm_btn o_we_text_success fa fa-check" title="${confirmText}"/>
<we-button class="o_we_cancel_btn o_we_text_danger fa fa-times" title="${cancelText}"/>
</div>
</we-input>
`);
$snippet.find('we-button').remove();
$snippet.find('span.oe_snippet_thumbnail_title').replaceWith($input);
const $textInput = $input.find('input');
$textInput.focus();
$textInput.select();
$snippet.find('.oe_snippet_thumbnail').addClass('o_we_already_dragging'); // prevent drag
$input.find('.o_we_confirm_btn').click(async () => {
const name = $textInput.val();
if (name !== snippetName) {
this._execWithLoadingEffect(async () => {
await this._rpc({
model: 'ir.ui.view',
method: 'rename_snippet',
kwargs: {
'name': name,
'view_id': parseInt(ev.target.dataset.snippetId),
'template_key': this.options.snippets,
},
});
}, true);
}
await this._loadSnippetsTemplates(name !== snippetName);
});
$input.find('.o_we_cancel_btn').click(async () => {
await this._loadSnippetsTemplates(false);
});
},
/**
* Prevents pointer-events to change the focus when a pointer slide from
* left-panel to the editable area.
@@ -4767,77 +4767,57 @@ registry.SnippetSave = SnippetOptionWidget.extend({
*/
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 save = await new Promise(resolve => {
Dialog.confirm(this, _t("To save a snippet, we need to save all your previous modifications and reload the page."), {
buttons: [
{
text: _t("Save and Reload"),
classes: 'btn-primary',
close: true,
click: () => resolve(true),
}, {
text: _t("Cancel"),
close: true,
click: () => resolve(false),
}
]
Dialog.confirm(this, _t("To save a snippet, we need to save all your previous modifications and reload the page."), {
cancel_callback: () => resolve(false),
buttons: [
{
text: _t("Save and Reload"),
classes: 'btn-primary',
close: true,
click: () => {
const snippetKey = this.$target[0].dataset.snippet;
let thumbnailURL;
this.trigger_up('snippet_thumbnail_url_request', {
key: snippetKey,
onSuccess: url => thumbnailURL = url,
});
});
if (!save) {
return;
let context;
this.trigger_up('context_get', {
callback: ctx => context = ctx,
});
this.trigger_up('request_save', {
reloadEditor: true,
onSuccess: async () => {
const defaultSnippetName = _.str.sprintf(_t("Custom %s"), this.data.snippetName);
const targetCopyEl = this.$target[0].cloneNode(true);
delete targetCopyEl.dataset.name;
// By the time onSuccess is called after request_save, the
// current widget has been destroyed and is orphaned, so this._rpc
// will not work as it can't trigger_up. For this reason, we need
// to bypass the service provider and use the global RPC directly
await rpc.query({
model: 'ir.ui.view',
method: 'save_snippet',
kwargs: {
'name': defaultSnippetName,
'arch': targetCopyEl.outerHTML,
'template_key': this.options.snippets,
'snippet_key': snippetKey,
'thumbnail_url': thumbnailURL,
'context': context,
},
});
},
});
resolve(true);
}
const snippetKey = this.$target[0].dataset.snippet;
let thumbnailURL;
this.trigger_up('snippet_thumbnail_url_request', {
key: snippetKey,
onSuccess: url => thumbnailURL = url,
});
let context;
this.trigger_up('context_get', {
callback: ctx => context = ctx,
});
this.trigger_up('request_save', {
reloadEditor: true,
onSuccess: async () => {
const snippetName = dialog.el.querySelector('.o_we_snippet_name_input').value;
const targetCopyEl = this.$target[0].cloneNode(true);
delete targetCopyEl.dataset.name;
// By the time onSuccess is called after request_save, the
// current widget has been destroyed and is orphaned, so this._rpc
// will not work as it can't trigger_up. For this reason, we need
// to bypass the service provider and use the global RPC directly
await rpc.query({
model: 'ir.ui.view',
method: 'save_snippet',
kwargs: {
'name': snippetName,
'arch': targetCopyEl.outerHTML,
'template_key': this.options.snippets,
'snippet_key': snippetKey,
'thumbnail_url': thumbnailURL,
'context': context,
},
});
},
});
},
}, {
text: _t("Discard"),
close: true,
}],
}).open();
dialog.on('closed', this, () => resolve());
}, {
text: _t("Cancel"),
close: true,
click: () => resolve(false),
}
]
});
});
},
});
@@ -301,6 +301,33 @@ body.editor_enable.editor_has_snippets {
}
}
%we-generic-text-input {
> div {
border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color;
border-radius: $o-we-sidebar-content-field-border-radius;
background-color: $o-we-sidebar-content-field-input-bg;
&:focus-within {
border-color: $o-we-sidebar-content-field-input-border-color;
}
input {
box-sizing: content-box;
padding: 0 $o-we-sidebar-content-field-clickable-spacing;
border: none;
border-radius: 0;
background-color: transparent;
color: inherit;
&:focus {
outline: none;
}
}
> we-button { // for input-group
border: none;
}
}
}
// SNIPPET PANEL
#oe_snippets {
@include o-w-preserve-btn;
@@ -446,10 +473,10 @@ body.editor_enable.editor_has_snippets {
#snippet_custom .oe_snippet {
width: 100%;
&, .oe_snippet_thumbnail, .o_delete_btn {
&, .oe_snippet_thumbnail, .btn {
display: flex;
}
.oe_snippet_thumbnail, .o_delete_btn {
.oe_snippet_thumbnail, .btn {
align-items: center;
}
.oe_snippet_thumbnail {
@@ -460,14 +487,39 @@ body.editor_enable.editor_has_snippets {
}
.oe_snippet_thumbnail_img {
flex-shrink: 0;
width: 40px;
height: 32px;
width: 41px;
height: 30px; // 82x60 -> 41x30
padding: 0;
}
.o_delete_btn {
.btn {
@extend %we-generic-link;
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
&:not(:hover) .btn {
display: none;
}
we-input.o_we_user_value_widget {
@extend %we-generic-text-input;
cursor: pointer;
> div {
display: flex;
}
input {
cursor: text;
}
we-button {
@extend %we-generic-clickable;
cursor: pointer;
flex: 1 1 auto;
line-height: 17px;
text-align: center;
&:hover {
background-color: gray;
}
}
}
}
}
@@ -890,46 +942,24 @@ body.editor_enable.editor_has_snippets {
// Inputs
we-input.o_we_user_value_widget {
@extend %we-generic-text-input;
> div {
flex: 0 1 auto;
width: $o-we-sidebar-content-field-input-max-width;
border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color;
border-radius: $o-we-sidebar-content-field-border-radius;
background-color: $o-we-sidebar-content-field-input-bg;
&:focus-within {
border-color: $o-we-sidebar-content-field-input-border-color;
}
> we-button { // for input-group
border: none;
}
}
&.o_we_large_input > div {
flex: 1 1 auto;
}
input {
box-sizing: content-box;
flex: 1 1 auto;
width: 0;
min-width: 2ch;
height: $o-we-sidebar-content-field-height - 2 * $o-we-sidebar-content-field-border-width;
padding: 0 $o-we-sidebar-content-field-clickable-spacing;
border: none;
border-radius: 0;
background-color: transparent;
color: inherit;
font-family: $o-we-sidebar-content-field-input-font-family;
&::placeholder {
color: $o-we-sidebar-content-field-control-item-color;
}
&:focus {
outline: none;
}
}
span {
flex: 0 0 auto;
@@ -25,18 +25,4 @@
</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
View File
@@ -62,3 +62,16 @@ class TestViews(TransactionCase):
'<p>Hello World!</p>',
base.inherit_children_ids.read_combined(['arch'])['arch'],
)
def test_find_available_name(self):
View = self.env['ir.ui.view']
used_names = ['Unrelated name']
initial_name = "Test name"
name = View._find_available_name(initial_name, used_names)
self.assertEqual(initial_name, name)
used_names.append(name)
name = View._find_available_name(initial_name, used_names)
self.assertEqual('Test name (2)', name)
used_names.append(name)
name = View._find_available_name(initial_name, used_names)
self.assertEqual('Test name (3)', name)