diff --git a/addons/test_website/static/tests/tours/custom_snippets.js b/addons/test_website/static/tests/tours/custom_snippets.js index 7fc066a1648..eb6a22f29b8 100644 --- a/addons/test_website/static/tests/tours/custom_snippets.js +++ b/addons/test_website/static/tests/tours/custom_snippets.js @@ -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 }, ]); diff --git a/addons/web_editor/models/ir_ui_view.py b/addons/web_editor/models/ir_ui_view.py index 3d7a4a734a4..13b369cb50b 100644 --- a/addons/web_editor/models/ir_ui_view.py +++ b/addons/web_editor/models/ir_ui_view.py @@ -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) 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 8abc8eec695..d608fa88546 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -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 = $(` + +
+ + + +
+
+ `); + $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. 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 d1216ea52a3..f04d2d3334c 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -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), + } + ] + }); }); }, }); diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss index 4ec20777c99..bdec1633cd6 100644 --- a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss +++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss @@ -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; diff --git a/addons/web_editor/static/src/xml/editor.xml b/addons/web_editor/static/src/xml/editor.xml index ee766a819d1..e7429f2b201 100644 --- a/addons/web_editor/static/src/xml/editor.xml +++ b/addons/web_editor/static/src/xml/editor.xml @@ -25,18 +25,4 @@ - - -
-
- -
- -
-
-
-
diff --git a/addons/web_editor/tests/test_views.py b/addons/web_editor/tests/test_views.py index 322e2a78430..ccfcbee7b0d 100644 --- a/addons/web_editor/tests/test_views.py +++ b/addons/web_editor/tests/test_views.py @@ -62,3 +62,16 @@ class TestViews(TransactionCase): '

Hello World!

', 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)