[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:
@@ -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
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user