[IMP] web_editor, website, *: introduce popover as edit system for links

*: website_form

This commit introduces a simple & light popover/tooltip (as in Google Doc) when
editing a link.

It will be used:
1. (web_editor) On every page links.
2. (website) In the website navbar, when clicking on a menu, it will replace the
popup shown to ask the user what he wants to do (edit the menu or go to the link
or do nothing). That popup was a bit invasive and old-fashioned.

Part of https://github.com/odoo/odoo/pull/64756
task-2439860

closes odoo/odoo#64756

Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
This commit is contained in:
Romain Derie
2021-04-01 09:49:45 +00:00
committed by qsm-odoo
parent fe67b34f1a
commit 8fcf930a6b
15 changed files with 501 additions and 52 deletions
+21 -1
View File
@@ -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');
}
@@ -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();
@@ -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();
}
@@ -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;
@@ -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,
@@ -470,6 +470,26 @@
</div>
</div>
<!-- Link Edition : Popover -->
<div t-name="wysiwyg.widgets.link.edit.tooltip" class="d-flex">
<span class="mr-2 o_we_preview_favicon"><i class="fa fa-globe"/><img class="align-baseline d-none"/></span>
<div class="w-100">
<div class="d-flex">
<a href="#" target="_blank" class="o_we_url_link font-weight-bold flex-grow-1" t-esc="widget.href" title="Open in a new tab"/>
<a href="#" class="mx-2 o_we_copy_link text-dark" data-toggle="tooltip" data-placement="top" title="Copy Link">
<i class="fa fa-clone"/>
</a>
<a href="#" class="mx-2 o_we_edit_link text-dark" data-toggle="tooltip" data-placement="top" title="Edit Link">
<i class="fa fa-edit"/>
</a>
<a href="#" class="ml-2 o_we_remove_link text-dark" data-toggle="tooltip" data-placement="top" title="Remove Link">
<i class="fa fa-chain-broken"/>
</a>
</div>
<a href="#" target="_blank" class="o_we_full_url mt-1 text-muted d-none" t-esc="widget.href" title="Open in a new tab"/>
</div>
</div>
<!-- ImageCropWidget controls (allows to crop images on the page) -->
<div t-name="wysiwyg.widgets.crop" class="o_we_crop_widget" contenteditable="false">
<div class="o_we_cropper_wrapper">
+1
View File
@@ -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'),
+1 -1
View File
@@ -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']:
@@ -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({
@@ -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 = $('<a/>', {
href: '#', class: 'ml-2 js_edit_menu', title: _t('Edit Menu'),
'data-placement': 'top', 'data-toggle': 'tooltip',
}).append($('<i/>', {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;
@@ -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;
}
}
}
@@ -4,10 +4,6 @@
<h2 class="mt0">Welcome to your <b>Homepage</b>!</h2>
<p class="lead d-none d-md-block">Click on <b>Edit</b> in the top right corner to start designing.</p>
</div>
<div t-name="website.leaving_current_page_edition">
<p>What do you want to do?</p>
<p class="text-muted">Your current changes will be saved automatically.</p>
</div>
<!-- Editor top bar which contains the summernote tools and save/discard buttons -->
<t t-name="website.editorbar">
@@ -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
},
]);
});
+3
View File
@@ -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")
@@ -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)',