diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js index 6258eacdb0e..ae6293a35cc 100644 --- a/addons/web_editor/static/src/js/editor/rte.js +++ b/addons/web_editor/static/src/js/editor/rte.js @@ -6,6 +6,7 @@ var concurrency = require('web.concurrency'); var core = require('web.core'); var Widget = require('web.Widget'); var weContext = require('web_editor.context'); +const weWidgets = require('wysiwyg.widgets'); var summernote = require('web_editor.summernote'); var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors'); @@ -676,6 +677,10 @@ var RTEWidget = Widget.extend({ if (this && this.$last && this.$last.length && this.$last[0] !== $target[0]) { $('.o_editable_date_field_linked').removeClass('o_editable_date_field_linked'); } + // Keep popover open if clicked inside it, but not on a button + if (!($target.parents('.o_edit_menu_popover').length && !$target.parent('a').addBack('a').length)) { + $('.o_edit_menu_popover').popover('hide'); + } if (!$editable.length || (!isLink && $.summernote.core.dom.isContentEditableFalse($target))) { return; } @@ -689,6 +694,11 @@ var RTEWidget = Widget.extend({ }); if (isLink) { + if (!$target.data('popover-widget-initialized')) { + weWidgets.LinkPopoverWidget.createFor(this, ev.target); + $target.data('popover-widget-initialized', true); + } + /** * Remove content editable everywhere and add it on the link only so that characters can be added * and removed at the start and at the end of it. @@ -702,7 +712,17 @@ var RTEWidget = Widget.extend({ // Once clicked outside, remove contenteditable on link and reactive all $(document).on('mousedown.reactivate_contenteditable', function (e) { - if ($target.is(e.target)) return; + // Keep popover open if clicked inside it, but not on a button + const $currTarget = $(e.target); + if (!($currTarget.parents('.o_edit_menu_popover').length && !$currTarget.parent('a').addBack('a').length)) { + $('.o_edit_menu_popover').popover('hide'); + } else { + return; + } + + if ($target.is(e.target)) { + return; + } if (!hasContentEditable) { $target.removeAttr('contenteditable'); } diff --git a/addons/web_editor/static/src/js/editor/rte.summernote.js b/addons/web_editor/static/src/js/editor/rte.summernote.js index ef4421d0826..ee0db26929d 100644 --- a/addons/web_editor/static/src/js/editor/rte.summernote.js +++ b/addons/web_editor/static/src/js/editor/rte.summernote.js @@ -883,14 +883,6 @@ eventHandler.attach = function (oLayoutInfo, options) { eventHandler.modules.imageDialog.show(oLayoutInfo); }); - /** - * Open Link Dialog on double click on a link/button. - * Shows a tooltip on click to say to the user he can double click. - */ - create_dblclick_feature("a[href], a.btn, button.btn", function () { - eventHandler.modules.linkDialog.show(oLayoutInfo); - }); - oLayoutInfo.editable().on('mousedown', function (e) { if (dom.isImg(e.target) && dom.isContentEditable(e.target)) { range.createFromNode(e.target).select(); 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 4b87155fbdb..56fa0cbeef0 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -1076,12 +1076,16 @@ var SnippetsMenu = Widget.extend({ if (!srcElement || lastElement === srcElement) { return; } + var $target = $(srcElement); + // Keep popover open if clicked inside it, but not on a button + if ($target.parents('.o_edit_menu_popover').length && !$target.parent('a').addBack('a').length) { + return; + } lastElement = srcElement; _.defer(function () { lastElement = false; }); - var $target = $(srcElement); if (!$target.closest('we-button, we-toggler, .o_we_color_preview').length) { this._closeWidgets(); } diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link_popover_widget.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link_popover_widget.js new file mode 100644 index 00000000000..13d811feb09 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_popover_widget.js @@ -0,0 +1,188 @@ +/** @odoo-module **/ + +import Widget from 'web.Widget'; +import {_t} from 'web.core'; + +const LinkPopoverWidget = Widget.extend({ + template: 'wysiwyg.widgets.link.edit.tooltip', + xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'], + events: { + 'click .o_we_remove_link': '_onRemoveLinkClick', + 'click .o_we_edit_link': '_onEditLinkClick', + }, + + /** + * @constructor + * @param {Element} target: target Element for which we display a popover + */ + init(parent, target) { + this._super(...arguments); + this.target = target; + this.$target = $(target); + this.href = this.$target.attr('href'); // for template + }, + /** + * + * @override + */ + start() { + this.$urlLink = this.$('.o_we_url_link'); + this.$previewFaviconImg = this.$('.o_we_preview_favicon img'); + this.$previewFaviconFa = this.$('.o_we_preview_favicon .fa'); + this.$copyLink = this.$('.o_we_copy_link'); + this.$fullUrl = this.$('.o_we_full_url'); + + // Copy onclick handler + const clipboard = new ClipboardJS( + this.$copyLink[0], + {text: () => this.target.href} // Absolute href + ); + clipboard.on('success', () => { + this.$copyLink.tooltip('hide'); + this.displayNotification({ + type: 'success', + message: _t("Link copied to clipboard."), + }); + }); + + // init tooltips & popovers + this.$('[data-toggle="tooltip"]').tooltip({delay: 0, placement: 'bottom'}); + this.$target.popover({ + html: true, + content: this.$el, + placement: 'bottom', + trigger: 'click', + }) + .on('show.bs.popover', () => { + this._loadAsyncLinkPreview(); + }) + .popover('show') + .data('bs.popover').tip.classList.add('o_edit_menu_popover'); + + return this._super(...arguments); + }, + /** + * + * @override + */ + destroy() { + this.$target.popover('dispose'); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Fetches and gets the link preview data (title, description..). + * For external URL, only the favicon will be loaded. + * + * @private + */ + _loadAsyncLinkPreview() { + let url; + try { + url = new URL(this.target.href); // relative to absolute + } catch (e) { + // Invalid URL, might happen with editor unsuported protocol. eg type + // `geo:37.786971,-122.399677`, become `http://geo:37.786971,-122.399677` + this.displayNotification({ + type: 'danger', + message: _t("This URL is invalid. Preview couldn't be updated."), + }); + return; + } + + this._resetPreview(url); + const protocol = url.protocol; + if (!protocol.startsWith('http')) { + const faMap = {'mailto:': 'fa-envelope-o', 'tel:': 'fa-phone'}; + const icon = faMap[protocol]; + if (icon) { + this.$previewFaviconFa.toggleClass(`fa-globe ${icon}`); + } + } else if (window.location.hostname !== url.hostname) { + // Preview pages from current website only. External website will + // most of the time raise a CORS error. To avoid that error, we + // would need to fetch the page through the server (s2s), involving + // enduser fetching problematic pages such as illicit content. + this.$previewFaviconImg.attr({ + 'src': `https://www.google.com/s2/favicons?sz=16&domain=${url}` + }).removeClass('d-none'); + this.$previewFaviconFa.addClass('d-none'); + } else { + $.get(this.target.href).then(content => { + const parser = new window.DOMParser(); + const doc = parser.parseFromString(content, "text/html"); + + // Get + const favicon = doc.querySelector("link[rel~='icon']"); + const ogTitle = doc.querySelector("[property='og:title']"); + const title = doc.querySelector("title"); + + // Set + if (favicon) { + this.$previewFaviconImg.attr({'src': favicon.href}).removeClass('d-none'); + this.$previewFaviconFa.addClass('d-none'); + } + if (ogTitle || title) { + this.$urlLink.text(ogTitle ? ogTitle.getAttribute('content') : title.text.trim()); + } + this.$fullUrl.removeClass('d-none').addClass('o_we_webkit_box'); + this.$target.popover('update'); + }); + } + }, + /** + * Resets the preview elements visibility. Particularly useful when changing + * the link url from an internal to an external one and vice versa. + * + * @private + * @param {string} url + */ + _resetPreview(url) { + this.$previewFaviconImg.addClass('d-none'); + this.$previewFaviconFa.removeClass('d-none').addClass('fa-globe'); + this.$urlLink.text(url).attr('href', url); + this.$fullUrl.text(url).addClass('d-none').removeClass('o_we_webkit_box'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Opens the Link Dialog. + * + * TODO Call business methods once new editor is released instead of click + * + * @private + * @param {Event} ev + */ + _onEditLinkClick(ev) { + $('.note-link-popover [data-event="showLinkDialog"]').click(); + }, + /** + * Removes the link/anchor. + * + * TODO Call business methods once new editor is released instead of click + * + * @private + * @param {Event} ev + */ + _onRemoveLinkClick(ev) { + $('.note-link-popover [data-event="unlink"]').click(); + }, +}); + +LinkPopoverWidget.createFor = async function (parent, targetEl) { + // Target might already have a popover, eg cart icon in navbar + if ($(targetEl).data('bs.popover')) { + return null; + } + const popoverWidget = new this(parent, targetEl); + return popoverWidget.appendTo(targetEl).then(() => popoverWidget); +}; + +export default LinkPopoverWidget; diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js index 64a9dc062f3..5afb2b7db49 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js @@ -6,6 +6,7 @@ var AltDialog = require('wysiwyg.widgets.AltDialog'); var MediaDialog = require('wysiwyg.widgets.MediaDialog'); var LinkDialog = require('wysiwyg.widgets.LinkDialog'); var ImageCropWidget = require('wysiwyg.widgets.ImageCropWidget'); +const LinkPopoverWidget = require('@web_editor/js/wysiwyg/widgets/link_popover_widget')[Symbol.for("default")]; const {ColorpickerDialog} = require('web.Colorpicker'); var media = require('wysiwyg.widgets.media'); @@ -16,6 +17,7 @@ return { MediaDialog: MediaDialog, LinkDialog: LinkDialog, ImageCropWidget: ImageCropWidget, + LinkPopoverWidget: LinkPopoverWidget, ColorpickerDialog: ColorpickerDialog, MediaWidget: media.MediaWidget, diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml index 49f39a47bd5..92b0022645b 100644 --- a/addons/web_editor/static/src/xml/wysiwyg.xml +++ b/addons/web_editor/static/src/xml/wysiwyg.xml @@ -470,6 +470,26 @@ + +
+ + +
+
diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 5f411f50dd4..c7d2d02cc13 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -180,6 +180,7 @@ 'website/static/src/js/editor/wysiwyg_multizone_translate.js', 'website/static/src/js/editor/widget_link.js', 'website/static/src/js/widgets/media.js', + 'website/static/src/js/widgets/link_popover_widget.js', ], 'website.assets_editor': [ ('include', 'web._assets_helpers'), diff --git a/addons/website/models/website_menu.py b/addons/website/models/website_menu.py index afd728ca9b0..1192646ef44 100644 --- a/addons/website/models/website_menu.py +++ b/addons/website/models/website_menu.py @@ -166,7 +166,7 @@ class Menu(models.Model): menu['id'] = new_id if menu['parent_id'] == old_id: menu['parent_id'] = new_id - to_delete = data['to_delete'] + to_delete = data.get('to_delete') if to_delete: self.browse(to_delete).unlink() for menu in data['data']: diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index 6859571d5ad..b8dac66b16a 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -8,6 +8,7 @@ var Dialog = require('web.Dialog'); const dom = require('web.dom'); const weUtils = require('web_editor.utils'); var options = require('web_editor.snippets.options'); +const wLinkPopoverWidget = require('@website/js/widgets/link_popover_widget')[Symbol.for("default")]; const wUtils = require('website.utils'); require('website.s_popup_options'); @@ -1184,45 +1185,27 @@ options.registry.ThemeColors = options.registry.OptionsTab.extend({ options.registry.menu_data = options.Class.extend({ /** - * When the users selects a menu, a dialog is opened to ask him if he wants - * to follow the link (and leave editor), edit the menu or do nothing. + * When the users selects a menu, a popover is shown with 4 possible + * actions: follow the link in a new tab, copy the menu link, edit the menu, + * or edit the menu tree. + * The popover shows a preview of the menu link. Remote URL only show the + * favicon. * * @override */ - onFocus: function () { - var self = this; - (new Dialog(this, { - title: _t("Confirmation"), - $content: $(core.qweb.render('website.leaving_current_page_edition')), - buttons: [ - {text: _t("Go to Link"), classes: 'btn-primary', click: function () { - self.trigger_up('request_save', { - reload: false, - onSuccess: function () { - window.location.href = self.$target.attr('href'); - }, - }); - }}, - {text: _t("Edit the menu"), classes: 'btn-primary', close: true, click: function () { - this.trigger_up('action_demand', { - actionName: 'edit_menu', - params: [ - function () { - var prom = new Promise(function (resolve, reject) { - self.trigger_up('request_save', { - onSuccess: resolve, - onFailure: reject, - }); - }); - return prom; - }, - ], - }); - }}, - {text: _t("Stay on this page"), close: true} - ] - })).open(); + start: function () { + wLinkPopoverWidget.createFor(this, this.$target[0]); + return this._super(...arguments); }, + /** + * When the users selects another element on the page, makes sure the + * popover is closed. + * + * @override + */ + onBlur: function () { + this.$target.popover('hide'); + }, }); options.registry.company_data = options.Class.extend({ diff --git a/addons/website/static/src/js/widgets/link_popover_widget.js b/addons/website/static/src/js/widgets/link_popover_widget.js new file mode 100644 index 00000000000..5ee4153fdfb --- /dev/null +++ b/addons/website/static/src/js/widgets/link_popover_widget.js @@ -0,0 +1,94 @@ +/** @odoo-module **/ + +import contentMenu from 'website.contentMenu'; +import weWidgets from 'wysiwyg.widgets'; +import {_t} from 'web.core'; + +const NavbarLinkPopoverWidget = weWidgets.LinkPopoverWidget.extend({ + events: _.extend({}, weWidgets.LinkPopoverWidget.prototype.events, { + 'click .js_edit_menu': '_onEditMenuClick', + }), + /** + * + * @override + */ + start() { + // remove link has no sense on navbar menu links, instead show edit menu + const $anchor = $('', { + href: '#', class: 'ml-2 js_edit_menu', title: _t('Edit Menu'), + 'data-placement': 'top', 'data-toggle': 'tooltip', + }).append($('', {class: 'fa fa-sitemap text-secondary'})); + this.$('.o_we_remove_link').replaceWith($anchor); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Opens the menu item editor. + * + * @override + * @param {Event} ev + */ + _onEditLinkClick(ev) { + var self = this; + var $menu = this.$target.find('[data-oe-id]'); + var dialog = new contentMenu.MenuEntryDialog(this, {}, null, { + name: $menu.text(), + url: $menu.parent().attr('href'), + }); + dialog.on('save', this, link => { + let websiteId; + this.trigger_up('context_get', { + callback: function (ctx) { + websiteId = ctx['website_id']; + }, + }); + const data = { + id: $menu.data('oe-id'), + name: link.text, + url: link.url, + }; + return this._rpc({ + model: 'website.menu', + method: 'save', + args: [websiteId, {'data': [data]}], + }).then(function () { + self.$target.attr('href', link.url); + $menu.text(link.text); + self._loadAsyncLinkPreview(); + }); + }); + dialog.open(); + }, + /** + * Opens the menu tree editor. On menu editor save, current page changes + * will also be saved. + * + * @private + * @param {Event} ev + */ + _onEditMenuClick(ev) { + this.trigger_up('action_demand', { + actionName: 'edit_menu', + params: [ + () => { + const prom = new Promise((resolve, reject) => { + this.trigger_up('request_save', { + onSuccess: resolve, + onFailure: reject, + }); + }); + return prom; + }, + ], + }); + }, +}); + +// Exact same static method but instantiating the specialized class. +NavbarLinkPopoverWidget.createFor = weWidgets.LinkPopoverWidget.createFor; + +export default NavbarLinkPopoverWidget; diff --git a/addons/website/static/src/scss/website.editor.ui.scss b/addons/website/static/src/scss/website.editor.ui.scss index 02f4175d078..0ba5dbb6af2 100644 --- a/addons/website/static/src/scss/website.editor.ui.scss +++ b/addons/website/static/src/scss/website.editor.ui.scss @@ -111,3 +111,30 @@ $o-we-switch-inactive-color: #F7F7F7 !default; width: 100%; } } + +.o_edit_menu_popover { + max-width: $popover-max-width * 1.2; + // Prevent UI glitch after fetching page preview (size might change) + width: $popover-max-width * 1.2; + + .o_we_url_link { + word-break: break-all; + } + + .o_we_full_url { + word-break: break-all; + overflow: hidden; + text-overflow: ellipsis; + // clamp (`-webkit-box` display toggle in js) + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + + &.o_we_webkit_box { + display: -webkit-box; + } + + &:hover { + -webkit-line-clamp: unset; + } + } +} diff --git a/addons/website/static/src/xml/website.editor.xml b/addons/website/static/src/xml/website.editor.xml index 2132bfce727..f2b2fcd230a 100644 --- a/addons/website/static/src/xml/website.editor.xml +++ b/addons/website/static/src/xml/website.editor.xml @@ -4,10 +4,6 @@

Welcome to your Homepage!

Click on Edit in the top right corner to start designing.

-
-

What do you want to do?

-

Your current changes will be saved automatically.

-
diff --git a/addons/website/static/tests/tours/edit_link_popover.js b/addons/website/static/tests/tours/edit_link_popover.js new file mode 100644 index 00000000000..92328e893c7 --- /dev/null +++ b/addons/website/static/tests/tours/edit_link_popover.js @@ -0,0 +1,117 @@ +odoo.define("website.tour.edit_link_popover", function (require) { +"use strict"; + +const tour = require('web_tour.tour'); +const wTourUtils = require('website.tour_utils'); + +tour.register('edit_link_popover', { + test: true, + url: '/?enable_editor=1', +}, [ + // 1. Test links in page content (web_editor) + wTourUtils.dragNDrop({ + id: 's_text_image', + name: 'Text - Image', + }), + { + content: "Click on a paragraph", + trigger: '#wrap .s_text_image p:nth-child(2)', + }, + { + content: "Click on 'Link' to open Link Dialog", + extra_trigger: '#wrap .s_text_image p:nth-child(2)', + trigger: ".note-air-popover [data-event='showLinkDialog']", + }, + { + content: "Type the link URL", + trigger: '#o_link_dialog_url_input', + run: 'text /contactus' + }, + { + content: "Save the Link Dialog modal", + trigger: '.modal-footer .btn-primary', + }, + { + content: "Click on newly created link", + trigger: '#wrap .s_text_image p:nth-child(2) a', + }, + { + content: "Popover should be shown", + trigger: '.o_edit_menu_popover .o_we_url_link:contains("Contact Us")', + run: function () {}, // it's a check + }, + { + content: "Click on Edit Link in Popover", + trigger: '.o_edit_menu_popover .o_we_edit_link', + }, + { + content: "Type the link URL", + trigger: '#o_link_dialog_url_input', + run: "text /" + }, + { + content: "Save the Link Dialog modal", + trigger: '.modal-footer .btn-primary', + }, + { + content: "Click on link", + trigger: '#wrap .s_text_image p:nth-child(2) a', + }, + { + content: "Popover should be shown with updated preview data", + trigger: '.o_edit_menu_popover .o_we_url_link:contains("Home")', + run: function () {}, // it's a check + }, + { + content: "Click on Remove Link in Popover", + trigger: '.o_edit_menu_popover .o_we_remove_link', + }, + { + content: "Link should be removed", + trigger: '#wrap .s_text_image p:nth-child(2):not(:has(a))', + run: function () {}, // it's a check + }, + // 2. Test links in navbar (website) + { + content: "Click navbar menu Home", + trigger: '#top_menu a:contains("Home")', + }, + { + content: "Popover should be shown", + trigger: '.o_edit_menu_popover .o_we_url_link:contains("Home")', + run: function () {}, // it's a check + }, + { + content: "Click on Edit Link in Popover", + trigger: '.o_edit_menu_popover .o_we_edit_link', + }, + { + content: "Change the URL", + trigger: '#o_link_dialog_url_input', + run: "text /contactus" + }, + { + content: "Save the Link Dialog modal", + trigger: '.modal-footer .btn-primary', + }, + { + content: "Click on the Home menu again", + trigger: '#top_menu a:contains("Home")', + }, + { + content: "Popover should be shown with updated preview data", + extra_trigger: '#top_menu a:contains("Home")[href="/contactus"]', // href should be changed + trigger: '.o_edit_menu_popover .o_we_url_link:contains("Contact Us")', + run: function () {}, // it's a check + }, + { + content: "Click on Edit Menu in Popover", + trigger: '.o_edit_menu_popover .js_edit_menu', + }, + { + content: "Edit Menu (tree) should open", + trigger: '.js_add_menu', + run: function () {}, // it's a check + }, +]); +}); diff --git a/addons/website/tests/test_ui.py b/addons/website/tests/test_ui.py index e89c1d3f1bc..7bfefbf67f9 100644 --- a/addons/website/tests/test_ui.py +++ b/addons/website/tests/test_ui.py @@ -199,3 +199,6 @@ class TestUi(odoo.tests.HttpCase): def test_08_website_style_custo(self): self.start_tour("/", "website_style_edition", login="admin") + + def test_09_website_edit_link_popover(self): + self.start_tour("/", "edit_link_popover", login="admin") diff --git a/addons/website_form/static/tests/tours/website_form_editor.js b/addons/website_form/static/tests/tours/website_form_editor.js index 97708dccc52..2016f1158c7 100644 --- a/addons/website_form/static/tests/tours/website_form_editor.js +++ b/addons/website_form/static/tests/tours/website_form_editor.js @@ -228,9 +228,11 @@ odoo.define('website_form_editor.tour', function (require) { // Edit the submit button using linkDialog. { - content: "Double click submit button to edit it", + content: "Click submit button to show edit popover", trigger: '.s_website_form_send', - run: 'dblclick', + }, { + content: "Click on Edit Link in Popover", + trigger: '.o_edit_menu_popover .o_we_edit_link', }, { content: "Check that no URL field is suggested", trigger: 'form:has(#o_link_dialog_label_input:hidden)',