[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
This commit is contained in:
Sébastien Theys
2018-09-13 18:18:02 +02:00
parent 7a298f89d9
commit a670f092b9
6 changed files with 487 additions and 433 deletions
+5
View File
@@ -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;
}
}
}
+6
View File
@@ -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.
+347 -194
View File
@@ -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.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.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._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,19 +521,40 @@ 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.
*
@@ -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'));
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
}));
// 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'));
// 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
//--------------------------------------------------------------------------
+1 -119
View File
@@ -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;
}
}
}
}
+86 -93
View File
@@ -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>
+42 -27
View File
@@ -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>