summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
authorSébastien Theys <seb@odoo.com>2018-09-13 18:15:01 +0200
committerSébastien Theys <seb@odoo.com>2018-09-13 18:18:02 +0200
commita670f092b936f41c3d1ced23bf62031ce185ea2b (patch)
tree7914426cf78b8457e8f2faf4e860d37855a894a6 /addons
parent7a298f89d95cb274e7d0ab91b06441404d6face7 (diff)
[IMP] website: improve Optimize SEO and Page Manager
Modal generic: - Fix footer overflowing buttons by pushing them to a new line. Page manager: - Add an SEO column to easily see which pages have incomplete SEO. - Add an edit SEO column to navigate to the SEO modal on said page. - Hide "edit in backend" column as debug. - Generally improve the whole view by adding appropriate titles on icons, etc. SEO modal: - Improve general style. - Add description length alert. - Restructure the keyword table/suggestions. - Reset page meta to their initial value on Discard. - Various small usability improvements. PR: #26897 task-1850579
Diffstat (limited to 'addons')
-rw-r--r--addons/web/static/src/scss/modal.scss5
-rw-r--r--addons/website/models/website.py6
-rw-r--r--addons/website/static/src/js/menu/seo.js541
-rw-r--r--addons/website/static/src/scss/website.ui.scss120
-rw-r--r--addons/website/static/src/xml/website.seo.xml179
-rw-r--r--addons/website/views/website_templates.xml69
6 files changed, 487 insertions, 433 deletions
diff --git a/addons/web/static/src/scss/modal.scss b/addons/web/static/src/scss/modal.scss
index c525050e1ec..f02765c25c4 100644
--- a/addons/web/static/src/scss/modal.scss
+++ b/addons/web/static/src/scss/modal.scss
@@ -26,6 +26,7 @@
}
.modal-footer {
+ flex-wrap: wrap;
text-align: left;
justify-content: flex-start;
@@ -39,6 +40,10 @@
> :not(:first-child) { margin-left: .25rem; }
> :not(:last-child) { margin-right: .25rem; }
}
+
+ button {
+ margin-bottom: .5rem;
+ }
}
}
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index 85c7a918423..7504fa5c2ec 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -676,11 +676,17 @@ class SeoMetadata(models.AbstractModel):
_name = 'website.seo.metadata'
_description = 'SEO metadata'
+ is_seo_optimized = fields.Boolean("SEO optimized", compute='_compute_is_seo_optimized')
website_meta_title = fields.Char("Website meta title", translate=True)
website_meta_description = fields.Text("Website meta description", translate=True)
website_meta_keywords = fields.Char("Website meta keywords", translate=True)
website_meta_og_img = fields.Char("Website opengraph image")
+ @api.multi
+ def _compute_is_seo_optimized(self):
+ for record in self:
+ record.is_seo_optimized = record.website_meta_title and record.website_meta_description and record.website_meta_keywords
+
def _default_website_meta(self):
""" This method will return default meta information. It return the dict
contains meta property as a key and meta content as a value.
diff --git a/addons/website/static/src/js/menu/seo.js b/addons/website/static/src/js/menu/seo.js
index 0f1c268dbb8..4267cad2bbf 100644
--- a/addons/website/static/src/js/menu/seo.js
+++ b/addons/website/static/src/js/menu/seo.js
@@ -17,22 +17,6 @@ var _t = core._t;
// Javascript \b is not unicode aware, and words beginning or ending by accents won't match \b
var WORD_SEPARATORS_REGEX = '([\\u2000-\\u206F\\u2E00-\\u2E7F\'!"#\\$%&\\(\\)\\*\\+,\\-\\.\\/:;<=>\\?¿¡@\\[\\]\\^_`\\{\\|\\}~\\s]+|^|$)';
-function analyzeKeyword(htmlPage, keyword) {
- return htmlPage.isInTitle(keyword) ? {
- title: 'badge badge-success',
- description: "This keyword is used in the page title",
- } : htmlPage.isInDescription(keyword) ? {
- title: 'badge badge-primary',
- description: "This keyword is used in the page description",
- } : htmlPage.isInBody(keyword) ? {
- title: 'badge badge-info',
- description: "This keyword is used in the page content."
- } : {
- title: 'badge badge-secondary',
- description: "This keyword is not used anywhere on the page."
- };
-}
-
var Suggestion = Widget.extend({
template: 'website.seo_suggestion',
xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
@@ -41,25 +25,9 @@ var Suggestion = Widget.extend({
},
init: function (parent, options) {
- this.root = options.root;
this.keyword = options.keyword;
- this.language = options.language;
- this.htmlPage = options.page;
this._super(parent);
},
- start: function () {
- this.htmlPage.on('title-changed', this, this.renderElement);
- this.htmlPage.on('description-changed', this, this.renderElement);
- },
- analyze: function () {
- return analyzeKeyword(this.htmlPage, this.keyword);
- },
- highlight: function () {
- return this.analyze().title;
- },
- tooltip: function () {
- return this.analyze().description;
- },
select: function () {
this.trigger('selected', this.keyword);
},
@@ -72,7 +40,7 @@ var SuggestionList = Widget.extend({
init: function (parent, options) {
this.root = options.root;
this.language = options.language;
- this.htmlPage = options.page;
+ this.htmlPage = options.htmlPage;
this._super(parent);
},
start: function () {
@@ -96,7 +64,7 @@ var SuggestionList = Widget.extend({
var self = this;
self.$el.empty();
// TODO Improve algorithm + Ajust based on custom user keywords
- var regex = new RegExp(self.root, 'gi');
+ var regex = new RegExp(WORD_SEPARATORS_REGEX + self.root + WORD_SEPARATORS_REGEX, 'gi');
keywords = _.map(_.uniq(keywords), function (word) {
return word.replace(regex, '').trim();
});
@@ -104,10 +72,7 @@ var SuggestionList = Widget.extend({
_.each(keywords, function (keyword) {
if (keyword) {
var suggestion = new Suggestion(self, {
- root: self.root,
- language: self.language,
keyword: keyword,
- page: self.htmlPage,
});
suggestion.on('selected', self, function (word, language) {
self.trigger('selected', word, language);
@@ -124,45 +89,53 @@ var Keyword = Widget.extend({
events: {
'click a[data-action=remove-keyword]': 'destroy',
},
- maxWordsPerKeyword: 4, // TODO Check
init: function (parent, options) {
this.keyword = options.word;
this.language = options.language;
- this.htmlPage = options.page;
+ this.htmlPage = options.htmlPage;
+ this.used_h1 = this.htmlPage.isInHeading1(this.keyword);
+ this.used_h2 = this.htmlPage.isInHeading2(this.keyword);
+ this.used_content = this.htmlPage.isInBody(this.keyword);
this._super(parent);
},
start: function () {
- this.htmlPage.on('title-changed', this, this.updateLabel);
- this.htmlPage.on('description-changed', this, this.updateLabel);
+ this.$('.js_seo_keyword_suggestion').empty();
this.suggestionList = new SuggestionList(this, {
root: this.keyword,
language: this.language,
- page: this.htmlPage,
+ htmlPage: this.htmlPage,
});
this.suggestionList.on('selected', this, function (word, language) {
this.trigger('selected', word, language);
});
this.suggestionList.appendTo(this.$('.js_seo_keyword_suggestion'));
- },
- analyze: function () {
- return analyzeKeyword(this.htmlPage, this.keyword);
- },
- highlight: function () {
- return this.analyze().title;
- },
- tooltip: function () {
- return this.analyze().description;
- },
- updateLabel: function () {
- var cssClass = 'oe_seo_keyword js_seo_keyword ' + this.highlight();
- this.$('.js_seo_keyword').attr('class', cssClass);
- this.$('.js_seo_keyword').attr('title', this.tooltip());
+
+ this.htmlPage.on('title-changed', this, this._updateTitle);
+ this.htmlPage.on('description-changed', this, this._updateDescription);
+ this._updateTitle();
+ this._updateDescription();
},
destroy: function () {
this.trigger('removed');
this._super();
},
+ _updateTitle: function () {
+ var $title = this.$('.js_seo_keyword_title');
+ if (this.htmlPage.isInTitle(this.keyword)) {
+ $title.css('visibility','visible');
+ } else {
+ $title.css('visibility','hidden');
+ }
+ },
+ _updateDescription: function () {
+ var $description = this.$('.js_seo_keyword_description');
+ if (this.htmlPage.isInDescription(this.keyword)) {
+ $description.css('visibility','visible');
+ } else {
+ $description.css('visibility','hidden');
+ }
+ },
});
var KeywordList = Widget.extend({
@@ -171,7 +144,7 @@ var KeywordList = Widget.extend({
maxKeywords: 10,
init: function (parent, options) {
- this.htmlPage = options.page;
+ this.htmlPage = options.htmlPage;
this._super(parent);
},
start: function () {
@@ -204,11 +177,10 @@ var KeywordList = Widget.extend({
var keyword = new Keyword(self, {
word: word,
language: language,
- page: this.htmlPage,
+ htmlPage: this.htmlPage,
});
keyword.on('removed', self, function () {
self.trigger('list-not-full');
- self.trigger('removed', word);
self.trigger('content-updated', true);
});
keyword.on('selected', self, function (word, language) {
@@ -230,15 +202,23 @@ var Preview = Widget.extend({
init: function (parent, options) {
this.title = options.title;
this.url = options.url;
- this.description = options.description || "[ The description will be generated by google unless you specify one ]";
+ this.description = options.description || _t("The description will be generated by search engines based on page content unless you specify one.");
+ if (this.description.length > 160) {
+ this.description = this.description.substring(0,159) + '…';
+ }
this._super(parent);
},
});
var HtmlPage = Class.extend(mixins.PropertiesMixin, {
+ init: function () {
+ mixins.PropertiesMixin.init.call(this);
+ this.initTitle = this.title();
+ this.initDescription = this.description();
+ },
url: function () {
var url = window.location.href;
- var hashIndex = url.indexOf('#');
+ var hashIndex = url.indexOf('?');
return hashIndex >= 0 ? url.substring(0, hashIndex) : url;
},
title: function () {
@@ -247,7 +227,7 @@ var HtmlPage = Class.extend(mixins.PropertiesMixin, {
},
changeTitle: function (title) {
// TODO create tag if missing
- $('title').text(title);
+ $('title').text(title.trim() || this.initTitle);
this.trigger('title-changed', title);
},
description: function () {
@@ -267,7 +247,6 @@ var HtmlPage = Class.extend(mixins.PropertiesMixin, {
changeKeywords: function (keywords) {
// TODO create tag if missing
$('meta[name=keywords]').attr('content', keywords.join(','));
- this.trigger('keywords-changed', keywords);
},
headers: function (tag) {
return $('#wrap '+tag).map(function () {
@@ -277,9 +256,11 @@ var HtmlPage = Class.extend(mixins.PropertiesMixin, {
getOgMeta: function () {
var ogImageUrl = $('meta[property="og:image"]').attr('content');
var title = $('meta[property="og:title"]').attr('content');
+ var description = $('meta[property="og:description"]').attr('content');
return {
ogImageUrl: ogImageUrl && ogImageUrl.replace(window.location.origin, ''),
metaTitle: title,
+ metaDescription: description,
};
},
images: function () {
@@ -295,7 +276,13 @@ var HtmlPage = Class.extend(mixins.PropertiesMixin, {
return $('html').attr('data-oe-company-name');
},
bodyText: function () {
- return $('body').children().not('.js_seo_configuration').text();
+ return $('body').children().not('.oe_seo_configuration').text();
+ },
+ heading1: function () {
+ return $('body').children().not('.oe_seo_configuration').find('h1').text();
+ },
+ heading2: function () {
+ return $('body').children().not('.oe_seo_configuration').find('h2').text();
},
isInBody: function (text) {
return new RegExp(WORD_SEPARATORS_REGEX+text+WORD_SEPARATORS_REGEX, 'gi').test(this.bodyText());
@@ -306,25 +293,222 @@ var HtmlPage = Class.extend(mixins.PropertiesMixin, {
isInDescription: function (text) {
return new RegExp(WORD_SEPARATORS_REGEX+text+WORD_SEPARATORS_REGEX, 'gi').test(this.description());
},
+ isInHeading1: function (text) {
+ return new RegExp(WORD_SEPARATORS_REGEX+text+WORD_SEPARATORS_REGEX, 'gi').test(this.heading1());
+ },
+ isInHeading2: function (text) {
+ return new RegExp(WORD_SEPARATORS_REGEX+text+WORD_SEPARATORS_REGEX, 'gi').test(this.heading2());
+ },
});
-var Tip = Widget.extend({
- template: 'website.seo_tip',
+var MetaTitleDescription = Widget.extend({
+ // Form and preview for SEO meta title and meta description
+ //
+ // We only want to show an alert for "description too small" on those cases
+ // - at init and the description is not empty
+ // - we reached past the minimum and went back to it
+ // - focus out of the field
+ // Basically we don't want the too small alert when the field is empty and
+ // we start typing on it.
+ template: 'website.seo_meta_title_description',
xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
events: {
- 'closed.bs.alert': 'destroy',
+ 'input input[name=website_meta_title]': '_titleChanged',
+ 'input textarea[name=website_meta_description]': '_descriptionOnInput',
+ 'change textarea[name=website_meta_description]': '_descriptionOnChange',
},
+ maxRecommendedDescriptionSize: 300,
+ minRecommendedDescriptionSize: 50,
+ showDescriptionTooSmall: false,
+ /**
+ * @override
+ */
init: function (parent, options) {
- this.message = options.message;
- // cf. http://getbootstrap.com/components/#alerts
- // success, info, warning or danger
- this.type = options.type || 'info';
- this._super(parent);
+ this.htmlPage = options.htmlPage;
+ this.canEditTitle = !!options.canEditTitle;
+ this.canEditDescription = !!options.canEditDescription;
+ this.isIndexed = !!options.isIndexed;
+ this._super(parent, options);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$title = this.$('input[name=website_meta_title]');
+ this.$description = this.$('textarea[name=website_meta_description]');
+ this.$warning = this.$('div#website_meta_description_warning');
+ this.$preview = this.$('.js_seo_preview');
+
+ this._renderPreview();
+
+ if (!this.canEditTitle) {
+ this.$title.attr('disabled', true);
+ }
+ if (!this.canEditDescription) {
+ this.$description.attr('disabled', true);
+ }
+
+ this.$title.val(this.htmlPage.title());
+ this.$description.val(this.htmlPage.description());
+
+ this._descriptionOnChange();
+ },
+ /**
+ * Get the current title
+ */
+ getTitle: function () {
+ return this.$title.val().trim() || this.htmlPage.initTitle;
+ },
+ /**
+ * Get the current description
+ */
+ getDescription: function () {
+ return this.$description.val();
+ },
+ /**
+ * @private
+ */
+ _titleChanged: function () {
+ var self = this;
+ self._renderPreview();
+ self.trigger('title-changed');
+ },
+ /**
+ * @private
+ */
+ _descriptionOnChange: function () {
+ this.showDescriptionTooSmall = true;
+ this._descriptionOnInput();
+ },
+ /**
+ * @private
+ */
+ _descriptionOnInput: function () {
+ var length = this.getDescription().length;
+
+ if (length >= this.minRecommendedDescriptionSize) {
+ this.showDescriptionTooSmall = true;
+ } else if (length === 0) {
+ this.showDescriptionTooSmall = false;
+ }
+
+ if (length > this.maxRecommendedDescriptionSize) {
+ this.$warning.text(_t('Your description looks too long.')).show();
+ } else if (this.showDescriptionTooSmall && length < this.minRecommendedDescriptionSize) {
+ this.$warning.text(_t('Your description looks too short.')).show();
+ } else {
+ this.$warning.hide();
+ }
+
+ this._renderPreview();
+ this.trigger('description-changed');
+ },
+ /**
+ * @private
+ */
+ _renderPreview: function () {
+ var indexed = this.isIndexed;
+ var preview = "";
+ if (indexed){
+ preview = new Preview(this, {
+ title: this.getTitle(),
+ description: this.getDescription(),
+ url: this.htmlPage.url(),
+ });
+ } else {
+ preview = new Preview(this, {
+ description: _t("You have hidden this page from search results. It won't be indexed by search engines."),
+ });
+ }
+ this.$preview.empty();
+ preview.appendTo(this.$preview);
+ },
+});
+
+var MetaKeywords = Widget.extend({
+ // Form and table for SEO meta keywords
+ template: 'website.seo_meta_keywords',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+ events: {
+ 'keyup input[name=website_meta_keywords]': '_confirmKeyword',
+ 'click button[data-action=add]': '_addKeyword',
+ },
+
+ init: function (parent, options) {
+ this.htmlPage = options.htmlPage;
+ this._super(parent, options);
+ },
+ start: function () {
+ var self = this;
+ this.$input = this.$('input[name=website_meta_keywords]');
+ this.keywordList = new KeywordList(this, { htmlPage: this.htmlPage });
+ this.keywordList.on('list-full', this, function () {
+ self.$input.attr({
+ readonly: 'readonly',
+ placeholder: "Remove a keyword first"
+ });
+ self.$('button[data-action=add]').prop('disabled', true).addClass('disabled');
+ });
+ this.keywordList.on('list-not-full', this, function () {
+ self.$input.removeAttr('readonly').attr('placeholder', "");
+ self.$('button[data-action=add]').prop('disabled', false).removeClass('disabled');
+ });
+ this.keywordList.on('selected', this, function (word, language) {
+ self.keywordList.add(word, language);
+ });
+ this.keywordList.on('content-updated', this, function (removed) {
+ self._updateTable(removed);
+ });
+ this.keywordList.insertAfter(this.$('.table thead'));
+
+ this._getLanguages();
+ this._updateTable();
+ },
+ _addKeyword: function () {
+ var $language = this.$('select[name=seo_page_language]');
+ var keyword = this.$input.val();
+ var language = $language.val().toLowerCase();
+ this.keywordList.add(keyword, language);
+ this.$input.val('').focus();
+ },
+ _confirmKeyword: function (e) {
+ if (e.keyCode === 13) {
+ this._addKeyword();
+ }
+ },
+ _getLanguages: function () {
+ var self = this;
+ this._rpc({
+ model: 'website',
+ method: 'get_languages',
+ args: [[weContext.get().website_id]],
+ context: weContext.get(),
+ }).then( function (data) {
+ self.$('#language-box').html(core.qweb.render('Configurator.language_promote', {
+ 'language': data,
+ 'def_lang': weContext.get().lang
+ }));
+ });
+ },
+ /*
+ * Show the table if there is at least one keyword. Hide it otherwise.
+ *
+ * @private
+ * @param {boolean} removed: a keyword is about to be removed,
+ * we need to exclude it from the count
+ */
+ _updateTable : function (removed) {
+ var min = removed ? 1 : 0;
+ if (this.keywordList.keywords().length > min) {
+ this.$('table').show();
+ } else {
+ this.$('table').hide();
+ }
},
});
-var metaImageSelector = Widget.extend({
+var MetaImageSelector = Widget.extend({
template: 'website.seo_meta_image_selector',
xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
events: {
@@ -337,20 +521,41 @@ var metaImageSelector = Widget.extend({
* @param {Object} data
*/
init: function (parent, data) {
- this.metaTitle = data.title;
+ this.metaTitle = data.title || '';
+ this._setDescription(data.description);
this.activeMetaImg = data.metaImg;
- this.serverUrl = window.location.origin;
+ this.serverUrl = data.htmlpage.url();
data.pageImages.unshift(_.str.sprintf('/web/image/res.company/%s/logo', odoo.session_info.website_company_id));
this.images = _.uniq(data.pageImages);
this.customImgUrl = _.contains(data.pageImages, data.metaImg) ? false : data.metaImg;
this._super(parent);
},
+ setTitle: function (title) {
+ this.metaTitle = title;
+ this._updateTemplateBody();
+ },
+ setDescription: function (description) {
+ this._setDescription(description);
+ this._updateTemplateBody();
+ },
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
+ * Set the description, applying ellipsis if too long.
+ *
+ * @private
+ */
+ _setDescription: function (description) {
+ this.metaDescription = description || _t("The description will be generated by social media based on page content unless you specify one.");
+ if (this.metaDescription.length > 160) {
+ this.metaDescription = this.metaDescription.substring(0,159) + '…';
+ }
+ },
+
+ /**
* Update template.
*
* @private
@@ -404,24 +609,15 @@ var SeoConfigurator = Dialog.extend({
xmlDependencies: Dialog.prototype.xmlDependencies.concat(
['/website/static/src/xml/website.seo.xml']
),
- events: {
- 'keyup input[name=seo_page_keywords]': 'confirmKeyword',
- 'blur input[name=seo_page_title]': 'titleChanged',
- 'blur textarea[name=seo_page_description]': 'descriptionChanged',
- 'click button[data-action=add]': 'addKeyword',
- },
canEditTitle: false,
canEditDescription: false,
canEditKeywords: false,
canEditLanguage: false,
- maxTitleSize: 65,
- maxDescriptionSize: 160, // TODO master: remove me and add warning
init: function (parent, options) {
options = options || {};
_.defaults(options, {
- title: _t('Promote Page'),
- subtitle: _t('Get this page efficiently referenced in search engines to attract more visitors.'),
+ title: _t('Optimize SEO'),
buttons: [
{text: _t('Save'), classes: 'btn-primary', click: this.update},
{text: _t('Discard'), close: true},
@@ -433,106 +629,71 @@ var SeoConfigurator = Dialog.extend({
start: function () {
var self = this;
- this.$modal.addClass('oe_seo_configuration js_seo_configuration');
+ this.$modal.addClass('oe_seo_configuration');
this.htmlPage = new HtmlPage();
- this.$('.js_seo_page_url').text(this.htmlPage.url());
- this.$('input[name=seo_page_title]').val(this.htmlPage.title());
- this.$('textarea[name=seo_page_description]').val(this.htmlPage.description());
- this.keywordList = new KeywordList(self, { page: this.htmlPage });
- this.keywordList.on('list-full', self, function () {
- self.$('input[name=seo_page_keywords]').attr({
- readonly: 'readonly',
- placeholder: "Remove a keyword first"
- });
- self.$('button[data-action=add]').prop('disabled', true).addClass('disabled');
- });
- this.keywordList.on('list-not-full', self, function () {
- self.$('input[name=seo_page_keywords]').removeAttr('readonly').attr('placeholder', "");
- self.$('button[data-action=add]').prop('disabled', false).removeClass('disabled');
- });
- this.keywordList.on('selected', self, function (word, language) {
- self.keywordList.add(word, language);
- });
- this.keywordList.on('content-updated', self, function (removed) {
- self.updateTable(removed);
- });
- this.keywordList.insertAfter(this.$('.table thead'));
- this.disableUnsavableFields().then(function(){
- self.renderPreview();
- self.metaImageSelector = new metaImageSelector(self, {
+ this.disableUnsavableFields().then(function () {
+ // Image selector
+ self.metaImageSelector = new MetaImageSelector(self, {
+ htmlpage: self.htmlPage,
title: self.htmlPage.getOgMeta().metaTitle,
+ description: self.htmlPage.getOgMeta().metaDescription,
metaImg : self.metaImg || self.htmlPage.getOgMeta().ogImageUrl,
pageImages : _.pluck(self.htmlPage.images().get(), 'src'),
});
- self.metaImageSelector.appendTo(self.$('.js_seo_keywords_list'));
- });
+ self.metaImageSelector.appendTo(self.$('.js_seo_image'));
+
+ // title and description
+ self.metaTitleDescription = new MetaTitleDescription(self, {
+ htmlPage: self.htmlPage,
+ canEditTitle: self.canEditTitle,
+ canEditDescription: self.canEditDescription,
+ isIndexed: self.isIndexed,
+ });
+ self.metaTitleDescription.on('title-changed', self, self.titleChanged);
+ self.metaTitleDescription.on('description-changed', self, self.descriptionChanged);
+ self.metaTitleDescription.appendTo(self.$('.js_seo_meta_title_description'));
- this.getLanguages();
- this.updateTable();
- },
- getLanguages: function () {
- var self = this;
- this._rpc({
- model: 'website',
- method: 'get_languages',
- args: [[weContext.get().website_id]],
- context: weContext.get(),
- }).then( function (data) {
- self.$('#language-box').html(core.qweb.render('Configurator.language_promote', {
- 'language': data,
- 'def_lang': weContext.get().lang
- }));
+ // keywords
+ self.metaKeywords = new MetaKeywords(self, {htmlPage: self.htmlPage});
+ self.metaKeywords.appendTo(self.$('.js_seo_meta_keywords'));
});
},
+ /*
+ * Reset meta tags to their initial value if not saved.
+ *
+ * @private
+ */
+ destroy: function () {
+ if (!this.savedData) {
+ this.htmlPage.changeTitle(this.htmlPage.initTitle);
+ this.htmlPage.changeDescription(this.htmlPage.initDescription);
+ }
+ this._super.apply(this, arguments);
+ },
disableUnsavableFields: function () {
var self = this;
return this.loadMetaData().then(function (data) {
+ // We only need a reload for COW when the copy is happening, therefore:
+ // - no reload if we are not editing a view (condition: website_id === undefined)
+ // - reload if generic page (condition: website_id === false)
+ self.reloadOnSave = data.website_id === undefined ? false : !data.website_id;
//If website.page, hide the google preview & tell user his page is currently unindexed
self.isIndexed = (data && ('website_indexed' in data)) ? data.website_indexed : true;
self.canEditTitle = data && ('website_meta_title' in data);
self.canEditDescription = data && ('website_meta_description' in data);
self.canEditKeywords = data && ('website_meta_keywords' in data);
self.metaImg = data.website_meta_og_img;
- if (!self.canEditTitle) {
- self.$('input[name=seo_page_title]').attr('disabled', true);
- }
- if (!self.canEditDescription) {
- self.$('textarea[name=seo_page_description]').attr('disabled', true);
- }
if (!self.canEditTitle && !self.canEditDescription && !self.canEditKeywords) {
+ // disable the button to prevent an error if the current page doesn't use the mixin
+ // we make the check here instead of on the view because we don't need to check
+ // at every page load, just when the rare case someone clicks on this link
+ // TODO don't show the modal but just an alert in this case
self.$footer.find('button[data-action=update]').attr('disabled', true);
}
});
},
- suggestImprovements: function () {
- var self = this;
- var tips = [];
- _.each(tips, function (tip) {
- displayTip(tip.message, tip.type);
- });
-
- function displayTip(message, type) {
- new Tip(self, {
- message: message,
- type: type,
- }).appendTo(self.$('.js_seo_tips'));
- }
- },
- confirmKeyword: function (e) {
- if (e.keyCode === 13) {
- this.addKeyword();
- }
- },
- addKeyword: function (word) {
- var $input = this.$('input[name=seo_page_keywords]');
- var $language = this.$('select[name=seo_page_language]');
- var keyword = _.isString(word) ? word : $input.val();
- var language = $language.val().toLowerCase();
- this.keywordList.add(keyword, language);
- $input.val('').focus();
- },
update: function () {
var self = this;
var data = {};
@@ -543,12 +704,20 @@ var SeoConfigurator = Dialog.extend({
data.website_meta_description = this.htmlPage.description();
}
if (this.canEditKeywords) {
- data.website_meta_keywords = this.keywordList.keywords().join(', ');
+ data.website_meta_keywords = this.metaKeywords.keywordList.keywords().join(', ');
}
data.website_meta_og_img = this.metaImageSelector.activeMetaImg;
this.saveMetaData(data).then(function () {
- self.htmlPage.changeKeywords(self.keywordList.keywords());
- self.close();
+ // We want to reload if we are editing a generic page
+ // because it will become a specific page after this change (COW)
+ // and we want the user to be on the page he just created.
+ if (self.reloadOnSave) {
+ window.location.href = self.htmlPage.url();
+ } else {
+ self.htmlPage.changeKeywords(self.metaKeywords.keywordList.keywords());
+ self.savedData = true;
+ self.close();
+ }
});
},
getMainObject: function () {
@@ -572,8 +741,9 @@ var SeoConfigurator = Dialog.extend({
} else {
var fields = ['website_meta_title', 'website_meta_description', 'website_meta_keywords'
,'website_meta_og_img'];
- if (obj.model == 'website.page'){
+ if (obj.model === 'website.page'){
fields.push('website_indexed');
+ fields.push('website_id');
}
rpc.query({
model: obj.model,
@@ -608,44 +778,19 @@ var SeoConfigurator = Dialog.extend({
titleChanged: function () {
var self = this;
_.defer(function () {
- var title = self.$('input[name=seo_page_title]').val();
+ var title = self.metaTitleDescription.getTitle();
self.htmlPage.changeTitle(title);
- self.renderPreview();
+ self.metaImageSelector.setTitle(title);
});
},
descriptionChanged: function () {
var self = this;
_.defer(function () {
- var description = self.$('textarea[name=seo_page_description]').val();
+ var description = self.metaTitleDescription.getDescription();
self.htmlPage.changeDescription(description);
- self.renderPreview();
+ self.metaImageSelector.setDescription(description);
});
},
- renderPreview: function () {
- var indexed = this.isIndexed;
- var preview = "";
- if(indexed){
- preview = new Preview(this, {
- title: this.htmlPage.title(),
- description: this.htmlPage.description(),
- url: this.htmlPage.url(),
- });
- }
- else{
- preview = new Preview(this, {
- description: _t("You have hidden this page from search results. It won't be indexed by search engines."),
- });
- }
- var $preview = this.$('.js_seo_preview');
- $preview.empty();
- preview.appendTo($preview);
- },
- updateTable : function (removed) {
- var self = this,
- val = removed ? (this.$el.find('tbody > tr').length - 1) : (this.$el.find('tbody > tr').length);
- this.$('table').toggleClass('js_seo_has_content', val > 0 );
- this.$el.scrollTop(self.$el[0].scrollHeight);
- },
});
var SeoMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
@@ -653,6 +798,14 @@ var SeoMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
'promote-current-page': '_promoteCurrentPage',
}),
+ init: function (parent, options) {
+ this._super(parent, options);
+
+ if (window.location.href.includes('enable_seo')) {
+ this._promoteCurrentPage();
+ }
+ },
+
//--------------------------------------------------------------------------
// Actions
//--------------------------------------------------------------------------
diff --git a/addons/website/static/src/scss/website.ui.scss b/addons/website/static/src/scss/website.ui.scss
index 19532adcc79..6cf572925c3 100644
--- a/addons/website/static/src/scss/website.ui.scss
+++ b/addons/website/static/src/scss/website.ui.scss
@@ -193,7 +193,6 @@ body .modal {
#language-box {
padding-right: 25px;
background-color: white;
- margin-left: -1px;
}
.o_seo_og_image {
.o_meta_img {
@@ -259,121 +258,7 @@ body .modal {
}
}
- .table {
- td {
- vertical-align: middle;
-
- &:first-child {
- padding-right: 15px;
- border-width: 0;
- width: 35%;
- }
-
- &:last-child {
- visibility: hidden;
- }
- }
-
- > tfoot {
- display: none;
- }
-
- &.js_seo_has_content {
- td {
- &:first-child {
- width: 55%;
- padding-right: 15px;
- border: 1px solid $o-we-color-text-light;
- text-align: right;
- background-color: $o-we-color-paper;
-
- @include media-breakpoint-up(lg) {
- width: 38%;
- }
- }
-
- &:last-child {
- border: none;
- visibility: visible;
- padding-left: 15px;
-
- @include media-breakpoint-up(lg) {
- padding-left: 50px;
- }
- }
- }
-
- tbody {
- td {
- padding: 5px;
- transition: padding .3s ease 0s;
-
- &:first-child {
- border-width: 0 1px;
-
- .label {
- position: relative;
- display: inline-block;
- padding: 7px 35px;
- font-size: 16px;
- font-weight: normal;
-
- .oe_remove {
- @include o-w-close-icon(10px, white, $o-we-color-danger, 2px); // FIXME
- @include o-position-absolute(5px, 5px);
- }
- }
- }
-
- &:last-child {
- .label {
- display: block;
- font-size: 12px;
- font-weight: normal;
- opacity: 0.8;
- cursor: pointer;
-
- &:hover {
- opacity: 1;
- }
- }
- }
- }
-
- tr {
- animation: fadeInDownSmall .3s ease 0s 1 normal none running;
-
- &:first-child {
- td:first-child {
- padding-top: 10px;
- }
- }
-
- &:last-child {
- td:first-child {
- padding-bottom: 10px;
- border-bottom-width: 1px;
- }
- }
- }
- }
-
- > tfoot {
- display: table-footer-group;
-
- hr {
- margin: 10px 0;
- }
-
- td, td:first-child {
- border: none;
- background: none;
- }
- }
- }
- }
-
- li.oe_seo_preview_g {
+ div.oe_seo_preview_g {
list-style: none;
font-family: arial, sans-serif;
@@ -396,9 +281,6 @@ body .modal {
font-size: 14px;
line-height: 18px;
}
- .st {
- height: 50px;
- }
}
}
}
diff --git a/addons/website/static/src/xml/website.seo.xml b/addons/website/static/src/xml/website.seo.xml
index 8ada1a3088a..e6bb6aa378a 100644
--- a/addons/website/static/src/xml/website.seo.xml
+++ b/addons/website/static/src/xml/website.seo.xml
@@ -6,72 +6,10 @@
</t>
</t>
- <div t-name="website.seo_configuration">
- <section class="row">
- <div class="col-lg-6">
- <div class="mt16" role="form">
- <div class="form-group row">
- <label for="seo_page_title" class="col-3 col-form-label">Page Title</label>
- <div class="col-9">
- <input type="text" name="seo_page_title" class="form-control" maxlength="70" size="70"/>
- </div>
- </div>
- <div class="form-group row">
- <label for="seo_page_description" class="col-3 col-form-label">Description</label>
- <div class="col-9">
- <textarea name="seo_page_description" class="form-control" style="max-width: 100%;height:145px;max-height:145px" t-att-maxlength="widget.maxDescriptionSize"/>
- </div>
- </div>
- </div>
- </div>
- <div class="col-lg-6">
- <h5 class="mt16">Preview <small>how your page will be listed on Google</small></h5>
- <div>
- <div class="card mb0">
- <div class="card-body">
- <div class="js_seo_preview"/>
- </div>
- </div>
- </div>
- </div>
- </section>
- <hr/>
- <div class="js_seo_tips" />
- <section class="js_seo_keywords_list">
- <h4 class="mt16">Define Keywords <small>describing your page content</small></h4>
- <table class="table table-responsive">
- <thead>
- <tr>
- <td>
- <div class="form-inline" role="form">
- <div class="input-group">
- <input type="text" style="min-width:170px" name="seo_page_keywords" class="form-control" placeholder="Keyword" maxlength="30"/>
- <span class="input-group-append">
- <select name="seo_page_language" id="language-box" class="btn form-control"/>
- </span>
- <span class="input-group-append">
- <button data-action="add" class="btn btn-success" type="button">Add</button>
- </span>
- </div>
- </div>
- </td>
- <td>Suggested<br/><small class="text-muted">Most searched topics related to your keywords, ordered by importance:</small></td>
- </tr>
- </thead>
- <tfoot>
- <tr>
- <td/>
- <td><hr/>
- <small class="text-muted">Legend:</small>
- <span class="badge badge-secondary">Not used</span>
- <span class="badge badge-success">In title</span>
- <span class="badge badge-primary">In description</span>
- <span class="badge badge-info">In page's content</span>
- </td>
- </tr>
- </tfoot>
- </table>
- </section>
+ <div t-name="website.seo_configuration" role="form">
+ <section class="js_seo_meta_title_description"/>
+ <section class="js_seo_meta_keywords"/>
+ <section class="js_seo_image"/>
</div>
<t t-name="website.seo_suggestion_list">
@@ -86,35 +24,27 @@
</tbody>
</t>
- <t t-name="website.seo_tip">
- <div t-attf-class="alert alert-#{ widget.type }" role="alert">
- <t t-raw="widget.message"/>
- </div>
- </t>
-
<t t-name="website.seo_keyword">
- <tr>
- <td>
- <span t-attf-title="#{ widget.tooltip() }" t-attf-class="oe_seo_keyword #{ widget.highlight() } js_seo_keyword" t-att-data-keyword="widget.keyword">
- <t t-raw="widget.keyword"/> <a href="#" class="oe_remove" data-action="remove-keyword"/>
- </span>
- </td>
- <td class="js_seo_keyword_suggestion">
- <!-- filled in JS -->
- </td>
+ <tr class="js_seo_keyword" t-att-data-keyword="widget.keyword">
+ <td t-esc="widget.keyword"/>
+ <td class="text-center"><i t-if="widget.used_h1" class="fa fa-check" t-attf-title="{{ widget.keyword }} is used in page first level heading"/></td>
+ <td class="text-center"><i t-if="widget.used_h2" class="fa fa-check" t-attf-title="{{ widget.keyword }} is used in page second level heading"/></td>
+ <td class="text-center"><i class="js_seo_keyword_title fa fa-check" style="visibility: hidden;" t-attf-title="{{ widget.keyword }} is used in page title"/></td>
+ <td class="text-center"><i class="js_seo_keyword_description fa fa-check" style="visibility: hidden;" t-attf-title="{{ widget.keyword }} is used in page description"/></td>
+ <td class="text-center"><i t-if="widget.used_content" class="fa fa-check" t-attf-title="{{ widget.keyword }} is used in page content"/></td>
+ <td class="js_seo_keyword_suggestion"/>
+ <td class="text-center"><a href="#" class="oe_remove" data-action="remove-keyword" t-attf-title="Remove {{ widget.keyword }}"><i class="fa fa-trash"/></a></td>
</tr>
</t>
<t t-name="website.seo_suggestion">
- <li class="list-inline-item oe_seo_suggestion">
- <span t-attf-title="#{ widget.tooltip() }" t-attf-class="oe_seo_keyword #{ widget.highlight() } js_seo_suggestion" t-att-data-keyword="widget.keyword">
- <t t-raw="widget.keyword"/>
- </span>
+ <li class="list-inline-item">
+ <span class="js_seo_suggestion badge badge-info" t-att-data-keyword="widget.keyword" t-esc="widget.keyword"/>
</li>
</t>
<t t-name="website.seo_preview">
- <li class="oe_seo_preview_g">
+ <div class="oe_seo_preview_g">
<div class="rc">
<div class="r"><t t-esc="widget.title"/></div>
<div class="s">
@@ -122,7 +52,70 @@
<div class="st"><t t-esc="widget.description"/></div>
</div>
</div>
- </li>
+ </div>
+ </t>
+
+ <div t-name="website.seo_meta_title_description">
+ <h4><small>Optimize this page's referencing in search engines</small></h4>
+ <div class="row">
+ <div class="col-lg-6">
+ <div class="form-group">
+ <label for="website_meta_title">
+ Title <i class="fa fa-question-circle-o" title="The title will take a default value unless you specify one."/>
+ </label>
+ <input type="text" name="website_meta_title" id="website_meta_title" class="form-control" maxlength="70" size="70"/>
+ </div>
+ <div class="form-group">
+ <label for="website_meta_description">
+ Description <i class="fa fa-question-circle-o" title="The description will be generated by search engines based on page content unless you specify one."/>
+ </label>
+ <textarea name="website_meta_description" id="website_meta_description" class="form-control" style="height:120px;"/>
+ <div class="alert alert-warning mt16 mb0 small" id="website_meta_description_warning" style="display: none;"/>
+ </div>
+ </div>
+ <div class="col-lg-6">
+ <div class="card-header">Preview</div>
+ <div class="card mb0 p-0">
+ <div class="card-body">
+ <div class="js_seo_preview"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <t t-name="website.seo_meta_keywords">
+ <label for="website_meta_keywords">
+ Keywords
+ </label>
+ <div class="form-inline" role="form">
+ <div class="input-group">
+ <input type="text" name="website_meta_keywords" id="website_meta_keywords" class="form-control" placeholder="Keyword" maxlength="30"/>
+ <span class="input-group-append">
+ <select name="seo_page_language" id="language-box" class="btn form-control"/>
+ </span>
+ <span class="input-group-append">
+ <button data-action="add" class="btn btn-success" type="button">Add</button>
+ </span>
+ </div>
+ </div>
+ <div class="table-responsive mt16">
+ <table class="table">
+ <thead>
+ <tr>
+ <th>Keyword</th>
+ <th class="text-center" title="Used in page first level heading">H1</th>
+ <th class="text-center" title="Used in page second level heading">H2</th>
+ <th class="text-center" title="Used in page title">T</th>
+ <th class="text-center" title="Used in page description">D</th>
+ <th class="text-center" title="Used in page content">C</th>
+ <th title="Most searched topics related to your keyword, ordered by importance">Related keywords</th>
+ <th class="text-center"><i class="fa fa-trash" title="Remove"/></th>
+ </tr>
+ </thead>
+ <!-- body inserted in JS -->
+ </table>
+ </div>
</t>
<div t-name="website.seo_meta_image_selector" class="o_seo_og_image">
@@ -130,10 +123,9 @@
</div>
<t t-name="website.og_image_body">
+ <h4><small>Select an image for social share</small></h4>
<div class="row">
- <div class="col-md-6">
- <h5 class="mt16 mb4">Image for Social Share</h5>
- <div class="text-muted mb32">Select an image to use in social share.</div>
+ <div class="col-lg-6">
<t t-foreach="widget.images" t-as="image">
<div t-attf-class="o_meta_img mt4 #{image === widget.activeMetaImg and ' o_active_image' or ''}">
<img t-att-src="image"/>
@@ -147,13 +139,14 @@
<i class="fa fa-upload"/>
</div>
</div>
- <div class="col-md-6">
- <h6 class="mt16">Preview <small>how your page will be displayed on social media</small></h6>
- <div class="card p-0">
+ <div class="col-lg-6">
+ <div class="card p-0 mb16">
+ <div class="card-header">Preview</div>
<img class="card-img-top o_meta_active_img" t-att-src="widget.activeMetaImg"/>
<div class="card-body px-3 py-2">
<h6 class="text-alpha card-title mb0"><t t-esc="widget.metaTitle"/></h6>
<small class="card-subtitle text-muted"><t t-esc="widget.serverUrl"/></small>
+ <p t-esc="widget.metaDescription"/>
</div>
</div>
</div>
diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml
index 568e4410534..73a290eb19e 100644
--- a/addons/website/views/website_templates.xml
+++ b/addons/website/views/website_templates.xml
@@ -132,13 +132,13 @@
'data-oe-company-name': res_company.name
}"/>
<t t-if="not title">
- <t t-if="not additional_title and main_object and 'name' in main_object">
- <t t-set="additional_title" t-value="main_object.name"/>
- </t>
<t t-if="main_object and 'website_meta_title' in main_object and main_object.website_meta_title">
<t t-set="title" t-value="main_object.website_meta_title"/>
</t>
<t t-else="">
+ <t t-if="not additional_title and main_object and 'name' in main_object">
+ <t t-set="additional_title" t-value="main_object.name"/>
+ </t>
<t t-set="title"><t t-if="additional_title"><t t-raw="additional_title"/> | </t><t t-raw="(website or res_company).name"/></t>
</t>
</t>
@@ -1195,7 +1195,8 @@ Sitemap: <t t-esc="url_root"/>sitemap.xml
<th class="text-center"><i title="Is the page included in the main menu?" class="fa fa-thumb-tack"></i></th>
<th class="text-center"><i title="Is the page published?" class="fa fa-eye"></i></th>
<th class="text-center"><i title="Is the page indexed by search engines?" class="fa fa-globe"></i></th>
- <th style='width:140px;'></th>
+ <th class="text-center"><i title="Is the page SEO optimized?" class="fa fa-search"></i></th>
+ <th></th>
</tr>
</thead>
<t t-set='prev_page' t-value='False' />
@@ -1219,31 +1220,45 @@ Sitemap: <t t-esc="url_root"/>sitemap.xml
</template>
<template id="one_page_line">
- <t t-set='specific_page' t-value="page.website_id" />
- <t t-set='final_page' t-value="(next_page and page.url != next_page.url) or not next_page or specific_page"/>
- <tr t-att-style='not final_page and "color:#999"'>
- <td>
+ <t t-set='specific_page' t-value="page.website_id"/>
+ <t t-set='final_page' t-value="(next_page and page.url != next_page.url) or not next_page or specific_page"/>
+ <tr t-att-style='not final_page and "color:#999"'>
+ <td>
<t groups="website.group_multi_website">
- <t t-if='specific_page and prev_page and prev_page.url == page.url and not prev_page.website_id'><i class="fa fa-level-up fa-rotate-90 ml32 mr4"></i></t>
- <i t-else="1" class="fa fa-globe mr4" t-att-style='specific_page and "visibility:hidden;"'/>
+ <i t-if='specific_page and prev_page and prev_page.url == page.url and not prev_page.website_id' class="fa fa-level-up fa-rotate-90 ml32 mr4"/>
+ <i t-else="1" class="fa fa-globe mr4" t-att-style="'visibility:hidden;' if specific_page else ''"/>
</t>
- <i t-if="page.is_homepage" class="fa fa-home" title="Home"></i> <span t-esc="page.name"/>
- </td>
- <td><a t-if='final_page' t-attf-href="{{page.url}}"><t t-esc="page.url"/></a></td>
- <td class="text-center"><i t-att-class="'fa fa-check' if page.menu_ids else 'fa fa-times text-muted'" t-att-title="'Checked' if page.menu_ids else 'Not checked'"></i></td>
- <t t-set='date_formatted'><t t-options='{"widget": "date"}' t-esc="page.date_publish"/></t>
- <td class="text-center">
- <i t-att-title="not page.is_visible and page.website_published and 'This page will be visible on ' + date_formatted"
- t-att-class="'fa fa-check' if page.is_visible and page.website_published else 'fa fa-eye-slash' if not page.is_visible and page.website_published else 'fa fa-times text-muted'"></i>
- </td>
- <td class="text-center"><i t-att-class="'fa fa-check' if page.website_indexed else 'fa fa-times text-muted'" t-att-title="'Checked' if page.website_indexed else 'Not checked'"></i></td>
- <td class="text-right" style="white-space:nowrap;">
- <a class="mr4 fa fa-cog js_page_properties" t-att-data-id="page.id" href="#" title="Manage this page"></a>
- <a class="mr4 fa fa-pencil-square-o" t-attf-href="/web#id=#{page.view_id.id}&amp;view_type=form&amp;model=ir.ui.view" title="Edit code in backend"></a>
- <a class="mr4 fa fa-clone js_clone_page" t-att-data-id="page.id" href="#" title="Clone this page"></a>
- <a class="fa fa-trash js_delete_page" t-att-data-id="page.id" href="#" title="Delete this page"></a>
- </td>
- </tr>
+ <i t-if="page.is_homepage" class="fa fa-home" title="Home"/> <span t-esc="page.name"/>
+ </td>
+ <td>
+ <a t-if='final_page' t-att-href="page.url"><t t-esc="page.url"/></a>
+ </td>
+ <td class="text-center">
+ <i t-if="page.menu_ids" class="fa fa-check" title="In main menu"/>
+ <i t-else="" class="fa fa-times text-muted" title="Not in main menu"/>
+ </td>
+ <td class="text-center">
+ <t t-set='date_formatted'><t t-options='{"widget": "date"}' t-esc="page.date_publish"/></t>
+ <i t-if="page.is_visible" class="fa fa-check" title="Visible"/>
+ <i t-elif="page.website_published" class="fa fa-eye-slash" t-attf-title="This page will be visible on {{ date_formatted }}"/>
+ <i t-else="" class="fa fa-times text-muted" title="Not visible"/>
+ </td>
+ <td class="text-center">
+ <i t-if="page.website_indexed" class="fa fa-check" title="Indexed"/>
+ <i t-else="" class="fa fa-times text-muted" title="Not indexed"/>
+ </td>
+ <td class="text-center">
+ <i t-if="page.is_seo_optimized" class="fa fa-check" title="SEO optimized"/>
+ <i t-else="" class="fa fa-times text-muted" title="Not SEO optimized"/>
+ </td>
+ <td class="text-right" style="white-space:nowrap;">
+ <a class="mr4 fa fa-cog js_page_properties" href="#" t-att-data-id="page.id" title="Manage this page"/>
+ <a class="mr4 fa fa-search" t-attf-href="{{ page.url}}?enable_seo" title="Optimize SEO of this page"/>
+ <a groups="base.group_no_one" class="mr4 fa fa-bug" t-attf-href="/web#id=#{page.view_id.id}&amp;view_type=form&amp;model=ir.ui.view" title="Edit code in backend"/>
+ <a class="mr4 fa fa-clone js_clone_page" t-att-data-id="page.id" href="#" title="Clone this page"/>
+ <a class="fa fa-trash js_delete_page" t-att-data-id="page.id" href="#" title="Delete this page"/>
+ </td>
+ </tr>
</template>
</odoo>