summaryrefslogtreecommitdiff
path: root/addons/website/static/src/js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/website/static/src/js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website/static/src/js')
-rw-r--r--addons/website/static/src/js/backend/button.js116
-rw-r--r--addons/website/static/src/js/backend/dashboard.js721
-rw-r--r--addons/website/static/src/js/backend/res_config_settings.js81
-rw-r--r--addons/website/static/src/js/content/compatibility.js38
-rw-r--r--addons/website/static/src/js/content/menu.js642
-rw-r--r--addons/website/static/src/js/content/ripple_effect.js72
-rw-r--r--addons/website/static/src/js/content/snippets.animation.js1092
-rw-r--r--addons/website/static/src/js/content/website_root.js350
-rw-r--r--addons/website/static/src/js/content/website_root_instance.js26
-rw-r--r--addons/website/static/src/js/editor/editor.js18
-rw-r--r--addons/website/static/src/js/editor/editor_menu.js256
-rw-r--r--addons/website/static/src/js/editor/editor_menu_translate.js109
-rw-r--r--addons/website/static/src/js/editor/mega_menu.js0
-rw-r--r--addons/website/static/src/js/editor/rte.summernote.js59
-rw-r--r--addons/website/static/src/js/editor/snippets.editor.js245
-rw-r--r--addons/website/static/src/js/editor/snippets.options.js2612
-rw-r--r--addons/website/static/src/js/editor/widget_link.js104
-rw-r--r--addons/website/static/src/js/editor/wysiwyg_multizone.js286
-rw-r--r--addons/website/static/src/js/editor/wysiwyg_multizone_translate.js301
-rw-r--r--addons/website/static/src/js/menu/content.js1129
-rw-r--r--addons/website/static/src/js/menu/customize.js219
-rw-r--r--addons/website/static/src/js/menu/debug_manager.js21
-rw-r--r--addons/website/static/src/js/menu/edit.js256
-rw-r--r--addons/website/static/src/js/menu/mobile_view.js68
-rw-r--r--addons/website/static/src/js/menu/navbar.js292
-rw-r--r--addons/website/static/src/js/menu/new_content.js350
-rw-r--r--addons/website/static/src/js/menu/seo.js902
-rw-r--r--addons/website/static/src/js/menu/translate.js88
-rw-r--r--addons/website/static/src/js/post_link.js25
-rw-r--r--addons/website/static/src/js/set_view_track.js89
-rw-r--r--addons/website/static/src/js/show_password.js48
-rw-r--r--addons/website/static/src/js/theme_preview_form.js154
-rw-r--r--addons/website/static/src/js/theme_preview_kanban.js61
-rw-r--r--addons/website/static/src/js/tours/homepage.js47
-rw-r--r--addons/website/static/src/js/tours/tour_utils.js291
-rw-r--r--addons/website/static/src/js/user_custom_javascript.js24
-rw-r--r--addons/website/static/src/js/utils.js295
-rw-r--r--addons/website/static/src/js/visitor_timezone.js1
-rw-r--r--addons/website/static/src/js/widget_iframe.js28
-rw-r--r--addons/website/static/src/js/widgets/ace.js92
-rw-r--r--addons/website/static/src/js/widgets/media.js14
41 files changed, 11622 insertions, 0 deletions
diff --git a/addons/website/static/src/js/backend/button.js b/addons/website/static/src/js/backend/button.js
new file mode 100644
index 00000000..bb3c5bd1
--- /dev/null
+++ b/addons/website/static/src/js/backend/button.js
@@ -0,0 +1,116 @@
+odoo.define('website.backend.button', function (require) {
+'use strict';
+
+var AbstractField = require('web.AbstractField');
+var core = require('web.core');
+var field_registry = require('web.field_registry');
+
+var _t = core._t;
+
+var WebsitePublishButton = AbstractField.extend({
+ className: 'o_stat_info',
+ supportedFieldTypes: ['boolean'],
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * A boolean field is always set since false is a valid value.
+ *
+ * @override
+ */
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * This widget is supposed to be used inside a stat button and, as such, is
+ * rendered the same way in edit and readonly mode.
+ *
+ * @override
+ * @private
+ */
+ _render: function () {
+ this.$el.empty();
+ var text = this.value ? _t("Published") : _t("Unpublished");
+ var hover = this.value ? _t("Unpublish") : _t("Publish");
+ var valColor = this.value ? 'text-success' : 'text-danger';
+ var hoverColor = this.value ? 'text-danger' : 'text-success';
+ var $val = $('<span>').addClass('o_stat_text o_not_hover ' + valColor).text(text);
+ var $hover = $('<span>').addClass('o_stat_text o_hover ' + hoverColor).text(hover);
+ this.$el.append($val).append($hover);
+ },
+});
+
+var WidgetWebsiteButtonIcon = AbstractField.extend({
+ template: 'WidgetWebsiteButtonIcon',
+ events: {
+ 'click': '_onClick',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.$icon = this.$('.o_button_icon');
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _render: function () {
+ this._super.apply(this, arguments);
+
+ var published = this.value;
+ var info = published ? _t("Published") : _t("Unpublished");
+ this.$el.attr('aria-label', info)
+ .prop('title', info);
+ this.$icon.toggleClass('text-danger', !published)
+ .toggleClass('text-success', published);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * Redirects to the website page of the record.
+ *
+ * @private
+ */
+ _onClick: function () {
+ this.trigger_up('button_clicked', {
+ attrs: {
+ type: 'object',
+ name: 'open_website_url',
+ },
+ record: this.record,
+ });
+ },
+});
+
+field_registry
+ .add('website_redirect_button', WidgetWebsiteButtonIcon)
+ .add('website_publish_button', WebsitePublishButton);
+});
diff --git a/addons/website/static/src/js/backend/dashboard.js b/addons/website/static/src/js/backend/dashboard.js
new file mode 100644
index 00000000..b7050675
--- /dev/null
+++ b/addons/website/static/src/js/backend/dashboard.js
@@ -0,0 +1,721 @@
+odoo.define('website.backend.dashboard', function (require) {
+'use strict';
+
+var AbstractAction = require('web.AbstractAction');
+var ajax = require('web.ajax');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var field_utils = require('web.field_utils');
+var pyUtils = require('web.py_utils');
+var session = require('web.session');
+var time = require('web.time');
+var web_client = require('web.web_client');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var COLORS = ["#1f77b4", "#aec7e8"];
+var FORMAT_OPTIONS = {
+ // allow to decide if utils.human_number should be used
+ humanReadable: function (value) {
+ return Math.abs(value) >= 1000;
+ },
+ // with the choices below, 1236 is represented by 1.24k
+ minDigits: 1,
+ decimals: 2,
+ // avoid comma separators for thousands in numbers when human_number is used
+ formatterCallback: function (str) {
+ return str;
+ },
+};
+
+var Dashboard = AbstractAction.extend({
+ hasControlPanel: true,
+ contentTemplate: 'website.WebsiteDashboardMain',
+ jsLibs: [
+ '/web/static/lib/Chart/Chart.js',
+ ],
+ events: {
+ 'click .js_link_analytics_settings': 'on_link_analytics_settings',
+ 'click .o_dashboard_action': 'on_dashboard_action',
+ 'click .o_dashboard_action_form': 'on_dashboard_action_form',
+ },
+
+ init: function(parent, context) {
+ this._super(parent, context);
+
+ this.DATE_FORMAT = time.getLangDateFormat();
+ this.date_range = 'week'; // possible values : 'week', 'month', year'
+ this.date_from = moment.utc().subtract(1, 'week');
+ this.date_to = moment.utc();
+
+ this.dashboards_templates = ['website.dashboard_header', 'website.dashboard_content'];
+ this.graphs = [];
+ this.chartIds = {};
+ },
+
+ willStart: function() {
+ var self = this;
+ return Promise.all([ajax.loadLibs(this), this._super()]).then(function() {
+ return self.fetch_data();
+ }).then(function(){
+ var website = _.findWhere(self.websites, {selected: true});
+ self.website_id = website ? website.id : false;
+ });
+ },
+
+ start: function() {
+ var self = this;
+ this._computeControlPanelProps();
+ return this._super().then(function() {
+ self.render_graphs();
+ });
+ },
+
+ on_attach_callback: function () {
+ this._isInDom = true;
+ this.render_graphs();
+ this._super.apply(this, arguments);
+ },
+ on_detach_callback: function () {
+ this._isInDom = false;
+ this._super.apply(this, arguments);
+ },
+ /**
+ * Fetches dashboard data
+ */
+ fetch_data: function() {
+ var self = this;
+ var prom = this._rpc({
+ route: '/website/fetch_dashboard_data',
+ params: {
+ website_id: this.website_id || false,
+ date_from: this.date_from.year()+'-'+(this.date_from.month()+1)+'-'+this.date_from.date(),
+ date_to: this.date_to.year()+'-'+(this.date_to.month()+1)+'-'+this.date_to.date(),
+ },
+ });
+ prom.then(function (result) {
+ self.data = result;
+ self.dashboards_data = result.dashboards;
+ self.currency_id = result.currency_id;
+ self.groups = result.groups;
+ self.websites = result.websites;
+ });
+ return prom;
+ },
+
+ on_link_analytics_settings: function(ev) {
+ ev.preventDefault();
+
+ var self = this;
+ var dialog = new Dialog(this, {
+ size: 'medium',
+ title: _t('Connect Google Analytics'),
+ $content: QWeb.render('website.ga_dialog_content', {
+ ga_key: this.dashboards_data.visits.ga_client_id,
+ ga_analytics_key: this.dashboards_data.visits.ga_analytics_key,
+ }),
+ buttons: [
+ {
+ text: _t("Save"),
+ classes: 'btn-primary',
+ close: true,
+ click: function() {
+ var ga_client_id = dialog.$el.find('input[name="ga_client_id"]').val();
+ var ga_analytics_key = dialog.$el.find('input[name="ga_analytics_key"]').val();
+ self.on_save_ga_client_id(ga_client_id, ga_analytics_key);
+ },
+ },
+ {
+ text: _t("Cancel"),
+ close: true,
+ },
+ ],
+ }).open();
+ },
+
+ on_go_to_website: function (ev) {
+ ev.preventDefault();
+ var website = _.findWhere(this.websites, {selected: true});
+ window.location.href = `/website/force/${website.id}`;
+ },
+
+ on_save_ga_client_id: function(ga_client_id, ga_analytics_key) {
+ var self = this;
+ return this._rpc({
+ route: '/website/dashboard/set_ga_data',
+ params: {
+ 'website_id': self.website_id,
+ 'ga_client_id': ga_client_id,
+ 'ga_analytics_key': ga_analytics_key,
+ },
+ }).then(function (result) {
+ if (result.error) {
+ self.do_warn(result.error.title, result.error.message);
+ return;
+ }
+ self.on_date_range_button('week');
+ });
+ },
+
+ render_dashboards: function() {
+ var self = this;
+ _.each(this.dashboards_templates, function(template) {
+ self.$('.o_website_dashboard').append(QWeb.render(template, {widget: self}));
+ });
+ },
+
+ render_graph: function(div_to_display, chart_values, chart_id) {
+ var self = this;
+
+ this.$(div_to_display).empty();
+ var $canvasContainer = $('<div/>', {class: 'o_graph_canvas_container'});
+ this.$canvas = $('<canvas/>').attr('id', chart_id);
+ $canvasContainer.append(this.$canvas);
+ this.$(div_to_display).append($canvasContainer);
+
+ var labels = chart_values[0].values.map(function (date) {
+ return moment(date[0], "YYYY-MM-DD", 'en');
+ });
+
+ var datasets = chart_values.map(function (group, index) {
+ return {
+ label: group.key,
+ data: group.values.map(function (value) {
+ return value[1];
+ }),
+ dates: group.values.map(function (value) {
+ return value[0];
+ }),
+ fill: false,
+ borderColor: COLORS[index],
+ };
+ });
+
+ var ctx = this.$canvas[0];
+ this.chart = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: datasets,
+ },
+ options: {
+ legend: {
+ display: false,
+ },
+ maintainAspectRatio: false,
+ scales: {
+ yAxes: [{
+ type: 'linear',
+ ticks: {
+ beginAtZero: true,
+ callback: this.formatValue.bind(this),
+ },
+ }],
+ xAxes: [{
+ ticks: {
+ callback: function (moment) {
+ return moment.format(self.DATE_FORMAT);
+ },
+ }
+ }],
+ },
+ tooltips: {
+ mode: 'index',
+ intersect: false,
+ bodyFontColor: 'rgba(0,0,0,1)',
+ titleFontSize: 13,
+ titleFontColor: 'rgba(0,0,0,1)',
+ backgroundColor: 'rgba(255,255,255,0.6)',
+ borderColor: 'rgba(0,0,0,0.2)',
+ borderWidth: 2,
+ callbacks: {
+ title: function (tooltipItems, data) {
+ return data.datasets[0].label;
+ },
+ label: function (tooltipItem, data) {
+ var moment = data.labels[tooltipItem.index];
+ var date = tooltipItem.datasetIndex === 0 ?
+ moment :
+ moment.subtract(1, self.date_range);
+ return date.format(self.DATE_FORMAT) + ': ' + self.formatValue(tooltipItem.yLabel);
+ },
+ labelColor: function (tooltipItem, chart) {
+ var dataset = chart.data.datasets[tooltipItem.datasetIndex];
+ return {
+ borderColor: dataset.borderColor,
+ backgroundColor: dataset.borderColor,
+ };
+ },
+ }
+ }
+ }
+ });
+ },
+
+ render_graphs: function() {
+ var self = this;
+ if (this._isInDom) {
+ _.each(this.graphs, function(e) {
+ var renderGraph = self.groups[e.group] &&
+ self.dashboards_data[e.name].summary.order_count;
+ if (!self.chartIds[e.name]) {
+ self.chartIds[e.name] = _.uniqueId('chart_' + e.name);
+ }
+ var chart_id = self.chartIds[e.name];
+ if (renderGraph) {
+ self.render_graph('.o_graph_' + e.name, self.dashboards_data[e.name].graph, chart_id);
+ }
+ });
+ this.render_graph_analytics(this.dashboards_data.visits.ga_client_id);
+ }
+ },
+
+ render_graph_analytics: function(client_id) {
+ if (!this.dashboards_data.visits || !this.dashboards_data.visits.ga_client_id) {
+ return;
+ }
+
+ this.load_analytics_api();
+
+ var $analytics_components = this.$('.js_analytics_components');
+ this.addLoader($analytics_components);
+
+ var self = this;
+ gapi.analytics.ready(function() {
+
+ $analytics_components.empty();
+ // 1. Authorize component
+ var $analytics_auth = $('<div>').addClass('col-lg-12');
+ window.onOriginError = function () {
+ $analytics_components.find('.js_unauthorized_message').remove();
+ self.display_unauthorized_message($analytics_components, 'not_initialized');
+ };
+ gapi.analytics.auth.authorize({
+ container: $analytics_auth[0],
+ clientid: client_id
+ });
+
+ $analytics_auth.appendTo($analytics_components);
+
+ self.handle_analytics_auth($analytics_components);
+ gapi.analytics.auth.on('signIn', function() {
+ delete window.onOriginError;
+ self.handle_analytics_auth($analytics_components);
+ });
+
+ });
+ },
+
+ on_date_range_button: function(date_range) {
+ if (date_range === 'week') {
+ this.date_range = 'week';
+ this.date_from = moment.utc().subtract(1, 'weeks');
+ } else if (date_range === 'month') {
+ this.date_range = 'month';
+ this.date_from = moment.utc().subtract(1, 'months');
+ } else if (date_range === 'year') {
+ this.date_range = 'year';
+ this.date_from = moment.utc().subtract(1, 'years');
+ } else {
+ console.log('Unknown date range. Choose between [week, month, year]');
+ return;
+ }
+
+ var self = this;
+ Promise.resolve(this.fetch_data()).then(function () {
+ self.$('.o_website_dashboard').empty();
+ self.render_dashboards();
+ self.render_graphs();
+ });
+
+ },
+
+ on_website_button: function(website_id) {
+ var self = this;
+ this.website_id = website_id;
+ Promise.resolve(this.fetch_data()).then(function () {
+ self.$('.o_website_dashboard').empty();
+ self.render_dashboards();
+ self.render_graphs();
+ });
+ },
+
+ on_reverse_breadcrumb: function() {
+ var self = this;
+ web_client.do_push_state({});
+ this.fetch_data().then(function() {
+ self.$('.o_website_dashboard').empty();
+ self.render_dashboards();
+ self.render_graphs();
+ });
+ },
+
+ on_dashboard_action: function (ev) {
+ ev.preventDefault();
+ var self = this
+ var $action = $(ev.currentTarget);
+ var additional_context = {};
+ if (this.date_range === 'week') {
+ additional_context = {search_default_week: true};
+ } else if (this.date_range === 'month') {
+ additional_context = {search_default_month: true};
+ } else if (this.date_range === 'year') {
+ additional_context = {search_default_year: true};
+ }
+ this._rpc({
+ route: '/web/action/load',
+ params: {
+ 'action_id': $action.attr('name'),
+ },
+ })
+ .then(function (action) {
+ action.domain = pyUtils.assembleDomains([action.domain, `[('website_id', '=', ${self.website_id})]`]);
+ return self.do_action(action, {
+ 'additional_context': additional_context,
+ 'on_reverse_breadcrumb': self.on_reverse_breadcrumb
+ });
+ });
+ },
+
+ on_dashboard_action_form: function (ev) {
+ ev.preventDefault();
+ var $action = $(ev.currentTarget);
+ this.do_action({
+ name: $action.attr('name'),
+ res_model: $action.data('res_model'),
+ res_id: $action.data('res_id'),
+ views: [[false, 'form']],
+ type: 'ir.actions.act_window',
+ }, {
+ on_reverse_breadcrumb: this.on_reverse_breadcrumb
+ });
+ },
+
+ /**
+ * @private
+ */
+ _computeControlPanelProps() {
+ const $searchview = $(QWeb.render("website.DateRangeButtons", {
+ widget: this,
+ }));
+ $searchview.find('button.js_date_range').click((ev) => {
+ $searchview.find('button.js_date_range.active').removeClass('active');
+ $(ev.target).addClass('active');
+ this.on_date_range_button($(ev.target).data('date'));
+ });
+ $searchview.find('button.js_website').click((ev) => {
+ $searchview.find('button.js_website.active').removeClass('active');
+ $(ev.target).addClass('active');
+ this.on_website_button($(ev.target).data('website-id'));
+ });
+
+ const $buttons = $(QWeb.render("website.GoToButtons"));
+ $buttons.on('click', this.on_go_to_website.bind(this));
+
+ this.controlPanelProps.cp_content = { $searchview, $buttons };
+ },
+
+ // Loads Analytics API
+ load_analytics_api: function() {
+ var self = this;
+ if (!("gapi" in window)) {
+ (function(w,d,s,g,js,fjs){
+ g=w.gapi||(w.gapi={});g.analytics={q:[],ready:function(cb){this.q.push(cb);}};
+ js=d.createElement(s);fjs=d.getElementsByTagName(s)[0];
+ js.src='https://apis.google.com/js/platform.js';
+ fjs.parentNode.insertBefore(js,fjs);js.onload=function(){g.load('analytics');};
+ }(window,document,'script'));
+ gapi.analytics.ready(function() {
+ self.analytics_create_components();
+ });
+ }
+ },
+
+ handle_analytics_auth: function($analytics_components) {
+ $analytics_components.find('.js_unauthorized_message').remove();
+
+ // Check if the user is authenticated and has the right to make API calls
+ if (!gapi.analytics.auth.getAuthResponse()) {
+ this.display_unauthorized_message($analytics_components, 'not_connected');
+ } else if (gapi.analytics.auth.getAuthResponse() && gapi.analytics.auth.getAuthResponse().scope.indexOf('https://www.googleapis.com/auth/analytics') === -1) {
+ this.display_unauthorized_message($analytics_components, 'no_right');
+ } else {
+ this.make_analytics_calls($analytics_components);
+ }
+ },
+
+ display_unauthorized_message: function($analytics_components, reason) {
+ $analytics_components.prepend($(QWeb.render('website.unauthorized_analytics', {reason: reason})));
+ },
+
+ make_analytics_calls: function($analytics_components) {
+ // 2. ActiveUsers component
+ var $analytics_users = $('<div>');
+ var activeUsers = new gapi.analytics.ext.ActiveUsers({
+ container: $analytics_users[0],
+ pollingInterval: 10,
+ });
+ $analytics_users.appendTo($analytics_components);
+
+ // 3. View Selector
+ var $analytics_view_selector = $('<div>').addClass('col-lg-12 o_properties_selection');
+ var viewSelector = new gapi.analytics.ViewSelector({
+ container: $analytics_view_selector[0],
+ });
+ viewSelector.execute();
+ $analytics_view_selector.appendTo($analytics_components);
+
+ // 4. Chart graph
+ var start_date = '7daysAgo';
+ if (this.date_range === 'month') {
+ start_date = '30daysAgo';
+ } else if (this.date_range === 'year') {
+ start_date = '365daysAgo';
+ }
+ var $analytics_chart_2 = $('<div>').addClass('col-lg-6 col-12');
+ var breakdownChart = new gapi.analytics.googleCharts.DataChart({
+ query: {
+ 'dimensions': 'ga:date',
+ 'metrics': 'ga:sessions',
+ 'start-date': start_date,
+ 'end-date': 'yesterday'
+ },
+ chart: {
+ type: 'LINE',
+ container: $analytics_chart_2[0],
+ options: {
+ title: 'All',
+ width: '100%',
+ tooltip: {isHtml: true},
+ }
+ }
+ });
+ $analytics_chart_2.appendTo($analytics_components);
+
+ // 5. Chart table
+ var $analytics_chart_1 = $('<div>').addClass('col-lg-6 col-12');
+ var mainChart = new gapi.analytics.googleCharts.DataChart({
+ query: {
+ 'dimensions': 'ga:medium',
+ 'metrics': 'ga:sessions',
+ 'sort': '-ga:sessions',
+ 'max-results': '6'
+ },
+ chart: {
+ type: 'TABLE',
+ container: $analytics_chart_1[0],
+ options: {
+ width: '100%'
+ }
+ }
+ });
+ $analytics_chart_1.appendTo($analytics_components);
+
+ // Events handling & animations
+
+ var table_row_listener;
+
+ viewSelector.on('change', function(ids) {
+ var options = {query: {ids: ids}};
+ activeUsers.set({ids: ids}).execute();
+ mainChart.set(options).execute();
+ breakdownChart.set(options).execute();
+
+ if (table_row_listener) { google.visualization.events.removeListener(table_row_listener); }
+ });
+
+ mainChart.on('success', function(response) {
+ var chart = response.chart;
+ var dataTable = response.dataTable;
+
+ table_row_listener = google.visualization.events.addListener(chart, 'select', function() {
+ var options;
+ if (chart.getSelection().length) {
+ var row = chart.getSelection()[0].row;
+ var medium = dataTable.getValue(row, 0);
+ options = {
+ query: {
+ filters: 'ga:medium==' + medium,
+ },
+ chart: {
+ options: {
+ title: medium,
+ }
+ }
+ };
+ } else {
+ options = {
+ chart: {
+ options: {
+ title: 'All',
+ }
+ }
+ };
+ delete breakdownChart.get().query.filters;
+ }
+ breakdownChart.set(options).execute();
+ });
+ });
+
+ // Add CSS animation to visually show the when users come and go.
+ activeUsers.once('success', function() {
+ var element = this.container.firstChild;
+ var timeout;
+
+ this.on('change', function(data) {
+ element = this.container.firstChild;
+ var animationClass = data.delta > 0 ? 'is-increasing' : 'is-decreasing';
+ element.className += (' ' + animationClass);
+
+ clearTimeout(timeout);
+ timeout = setTimeout(function() {
+ element.className = element.className.replace(/ is-(increasing|decreasing)/g, '');
+ }, 3000);
+ });
+ });
+ },
+
+ /*
+ * Credits to https://github.com/googleanalytics/ga-dev-tools
+ * This is the Active Users component that polls
+ * the number of active users on Analytics each 5 secs
+ */
+ analytics_create_components: function() {
+
+ gapi.analytics.createComponent('ActiveUsers', {
+
+ initialize: function() {
+ this.activeUsers = 0;
+ gapi.analytics.auth.once('signOut', this.handleSignOut_.bind(this));
+ },
+
+ execute: function() {
+ // Stop any polling currently going on.
+ if (this.polling_) {
+ this.stop();
+ }
+
+ this.render_();
+
+ // Wait until the user is authorized.
+ if (gapi.analytics.auth.isAuthorized()) {
+ this.pollActiveUsers_();
+ } else {
+ gapi.analytics.auth.once('signIn', this.pollActiveUsers_.bind(this));
+ }
+ },
+
+ stop: function() {
+ clearTimeout(this.timeout_);
+ this.polling_ = false;
+ this.emit('stop', {activeUsers: this.activeUsers});
+ },
+
+ render_: function() {
+ var opts = this.get();
+
+ // Render the component inside the container.
+ this.container = typeof opts.container === 'string' ?
+ document.getElementById(opts.container) : opts.container;
+
+ this.container.innerHTML = opts.template || this.template;
+ this.container.querySelector('b').innerHTML = this.activeUsers;
+ },
+
+ pollActiveUsers_: function() {
+ var options = this.get();
+ var pollingInterval = (options.pollingInterval || 5) * 1000;
+
+ if (isNaN(pollingInterval) || pollingInterval < 5000) {
+ throw new Error('Frequency must be 5 seconds or more.');
+ }
+
+ this.polling_ = true;
+ gapi.client.analytics.data.realtime
+ .get({ids:options.ids, metrics:'rt:activeUsers'})
+ .then(function(response) {
+ var result = response.result;
+ var newValue = result.totalResults ? +result.rows[0][0] : 0;
+ var oldValue = this.activeUsers;
+
+ this.emit('success', {activeUsers: this.activeUsers});
+
+ if (newValue !== oldValue) {
+ this.activeUsers = newValue;
+ this.onChange_(newValue - oldValue);
+ }
+
+ if (this.polling_) {
+ this.timeout_ = setTimeout(this.pollActiveUsers_.bind(this), pollingInterval);
+ }
+ }.bind(this));
+ },
+
+ onChange_: function(delta) {
+ var valueContainer = this.container.querySelector('b');
+ if (valueContainer) { valueContainer.innerHTML = this.activeUsers; }
+
+ this.emit('change', {activeUsers: this.activeUsers, delta: delta});
+ if (delta > 0) {
+ this.emit('increase', {activeUsers: this.activeUsers, delta: delta});
+ } else {
+ this.emit('decrease', {activeUsers: this.activeUsers, delta: delta});
+ }
+ },
+
+ handleSignOut_: function() {
+ this.stop();
+ gapi.analytics.auth.once('signIn', this.handleSignIn_.bind(this));
+ },
+
+ handleSignIn_: function() {
+ this.pollActiveUsers_();
+ gapi.analytics.auth.once('signOut', this.handleSignOut_.bind(this));
+ },
+
+ template:
+ '<div class="ActiveUsers">' +
+ 'Active Users: <b class="ActiveUsers-value"></b>' +
+ '</div>'
+
+ });
+ },
+
+ // Utility functions
+ addLoader: function(selector) {
+ var loader = '<span class="fa fa-3x fa-spin fa-spinner fa-pulse"/>';
+ selector.html("<div class='o_loader'>" + loader + "</div>");
+ },
+ getValue: function(d) { return d[1]; },
+ format_number: function(value, type, digits, symbol) {
+ if (type === 'currency') {
+ return this.render_monetary_field(value, this.currency_id);
+ } else {
+ return field_utils.format[type](value || 0, {digits: digits}) + ' ' + symbol;
+ }
+ },
+ formatValue: function (value) {
+ var formatter = field_utils.format.float;
+ var formatedValue = formatter(value, undefined, FORMAT_OPTIONS);
+ return formatedValue;
+ },
+ render_monetary_field: function(value, currency_id) {
+ var currency = session.get_currency(currency_id);
+ var formatted_value = field_utils.format.float(value || 0, {digits: currency && currency.digits});
+ if (currency) {
+ if (currency.position === "after") {
+ formatted_value += currency.symbol;
+ } else {
+ formatted_value = currency.symbol + formatted_value;
+ }
+ }
+ return formatted_value;
+ },
+
+});
+
+core.action_registry.add('backend_dashboard', Dashboard);
+
+return Dashboard;
+});
diff --git a/addons/website/static/src/js/backend/res_config_settings.js b/addons/website/static/src/js/backend/res_config_settings.js
new file mode 100644
index 00000000..6dd014c0
--- /dev/null
+++ b/addons/website/static/src/js/backend/res_config_settings.js
@@ -0,0 +1,81 @@
+odoo.define('website.settings', function (require) {
+
+const BaseSettingController = require('base.settings').Controller;
+const core = require('web.core');
+const Dialog = require('web.Dialog');
+const FieldBoolean = require('web.basic_fields').FieldBoolean;
+const fieldRegistry = require('web.field_registry');
+const FormController = require('web.FormController');
+
+const QWeb = core.qweb;
+const _t = core._t;
+
+BaseSettingController.include({
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Bypasses the discard confirmation dialog when going to a website because
+ * the target website will be the one selected and when selecting a theme
+ * because the theme will be installed on the selected website.
+ *
+ * Without this override, it is impossible to go to a website other than the
+ * first because discarding will revert it back to the default value.
+ *
+ * Without this override, it is impossible to edit robots.txt website other than the
+ * first because discarding will revert it back to the default value.
+ *
+ * Without this override, it is impossible to submit sitemap to google other than for the
+ * first website because discarding will revert it back to the default value.
+ *
+ * Without this override, it is impossible to install a theme on a website
+ * other than the first because discarding will revert it back to the
+ * default value.
+ *
+ * @override
+ */
+ _onButtonClicked: function (ev) {
+ if (ev.data.attrs.name === 'website_go_to'
+ || ev.data.attrs.name === 'action_open_robots'
+ || ev.data.attrs.name === 'action_ping_sitemap'
+ || ev.data.attrs.name === 'install_theme_on_current_website') {
+ FormController.prototype._onButtonClicked.apply(this, arguments);
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+});
+
+const WebsiteCookiesbarField = FieldBoolean.extend({
+ xmlDependencies: ['/website/static/src/xml/website.res_config_settings.xml'],
+
+ _onChange: function () {
+ const checked = this.$input[0].checked;
+ if (!checked) {
+ return this._setValue(checked);
+ }
+
+ const cancelCallback = () => this.$input[0].checked = !checked;
+ Dialog.confirm(this, null, {
+ title: _t("Please confirm"),
+ $content: QWeb.render('website.res_config_settings.cookies_modal_main'),
+ buttons: [{
+ text: _t('Do not activate'),
+ classes: 'btn-primary',
+ close: true,
+ click: cancelCallback,
+ },
+ {
+ text: _t('Activate anyway'),
+ close: true,
+ click: () => this._setValue(checked),
+ }],
+ cancel_callback: cancelCallback,
+ });
+ },
+});
+
+fieldRegistry.add('website_cookiesbar_field', WebsiteCookiesbarField);
+});
diff --git a/addons/website/static/src/js/content/compatibility.js b/addons/website/static/src/js/content/compatibility.js
new file mode 100644
index 00000000..f4148802
--- /dev/null
+++ b/addons/website/static/src/js/content/compatibility.js
@@ -0,0 +1,38 @@
+odoo.define('website.content.compatibility', function (require) {
+'use strict';
+
+/**
+ * Tweaks the website rendering so that the old browsers correctly render the
+ * content too.
+ */
+
+require('web.dom_ready');
+
+// Check the browser and its version and add the info as an attribute of the
+// HTML element so that css selectors can match it
+var browser = _.findKey($.browser, function (v) { return v === true; });
+if ($.browser.mozilla && +$.browser.version.replace(/^([0-9]+\.[0-9]+).*/, '\$1') < 20) {
+ browser = 'msie';
+}
+browser += (',' + $.browser.version);
+var mobileRegex = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i;
+if (mobileRegex.test(window.navigator.userAgent.toLowerCase())) {
+ browser += ',mobile';
+}
+document.documentElement.setAttribute('data-browser', browser);
+
+// Check if flex is supported and add the info as an attribute of the HTML
+// element so that css selectors can match it (only if not supported)
+var htmlStyle = document.documentElement.style;
+var isFlexSupported = (('flexWrap' in htmlStyle)
+ || ('WebkitFlexWrap' in htmlStyle)
+ || ('msFlexWrap' in htmlStyle));
+if (!isFlexSupported) {
+ document.documentElement.setAttribute('data-no-flex', '');
+}
+
+return {
+ browser: browser,
+ isFlexSupported: isFlexSupported,
+};
+});
diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js
new file mode 100644
index 00000000..71f74ab5
--- /dev/null
+++ b/addons/website/static/src/js/content/menu.js
@@ -0,0 +1,642 @@
+odoo.define('website.content.menu', function (require) {
+'use strict';
+
+const config = require('web.config');
+var dom = require('web.dom');
+var publicWidget = require('web.public.widget');
+var wUtils = require('website.utils');
+var animations = require('website.content.snippets.animation');
+
+const extraMenuUpdateCallbacks = [];
+
+const BaseAnimatedHeader = animations.Animation.extend({
+ disabledInEditableMode: false,
+ effects: [{
+ startEvents: 'scroll',
+ update: '_updateHeaderOnScroll',
+ }, {
+ startEvents: 'resize',
+ update: '_updateHeaderOnResize',
+ }],
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super(...arguments);
+ this.fixedHeader = false;
+ this.scrolledPoint = 0;
+ this.hasScrolled = false;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$main = this.$el.next('main');
+ this.isOverlayHeader = !!this.$el.closest('.o_header_overlay, .o_header_overlay_theme').length;
+ this.$dropdowns = this.$el.find('.dropdown, .dropdown-menu');
+ this.$navbarCollapses = this.$el.find('.navbar-collapse');
+
+ // While scrolling through navbar menus on medium devices, body should not be scrolled with it
+ this.$navbarCollapses.on('show.bs.collapse.BaseAnimatedHeader', function () {
+ if (config.device.size_class <= config.device.SIZES.SM) {
+ $(document.body).addClass('overflow-hidden');
+ }
+ }).on('hide.bs.collapse.BaseAnimatedHeader', function () {
+ $(document.body).removeClass('overflow-hidden');
+ });
+
+ // We can rely on transitionend which is well supported but not on
+ // transitionstart, so we listen to a custom odoo event.
+ this._transitionCount = 0;
+ this.$el.on('odoo-transitionstart.BaseAnimatedHeader', () => this._adaptToHeaderChangeLoop(1));
+ this.$el.on('transitionend.BaseAnimatedHeader', () => this._adaptToHeaderChangeLoop(-1));
+
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._toggleFixedHeader(false);
+ this.$el.removeClass('o_header_affixed o_header_is_scrolled o_header_no_transition');
+ this.$navbarCollapses.off('.BaseAnimatedHeader');
+ this.$el.off('.BaseAnimatedHeader');
+ this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adaptFixedHeaderPosition() {
+ dom.compensateScrollbar(this.el, this.fixedHeader, false, 'right');
+ },
+ /**
+ * @private
+ */
+ _adaptToHeaderChange: function () {
+ this._updateMainPaddingTop();
+ this.el.classList.toggle('o_top_fixed_element', this.fixedHeader && this._isShown());
+
+ for (const callback of extraMenuUpdateCallbacks) {
+ callback();
+ }
+ },
+ /**
+ * @private
+ * @param {integer} [addCount=0]
+ */
+ _adaptToHeaderChangeLoop: function (addCount = 0) {
+ this._adaptToHeaderChange();
+
+ this._transitionCount += addCount;
+ this._transitionCount = Math.max(0, this._transitionCount);
+
+ // As long as we detected a transition start without its related
+ // transition end, keep updating the main padding top.
+ if (this._transitionCount > 0) {
+ window.requestAnimationFrame(() => this._adaptToHeaderChangeLoop());
+
+ // The normal case would be to have the transitionend event to be
+ // fired but we cannot rely on it, so we use a timeout as fallback.
+ if (addCount !== 0) {
+ clearTimeout(this._changeLoopTimer);
+ this._changeLoopTimer = setTimeout(() => {
+ this._adaptToHeaderChangeLoop(-this._transitionCount);
+ }, 500);
+ }
+ } else {
+ // When we detected all transitionend events, we need to stop the
+ // setTimeout fallback.
+ clearTimeout(this._changeLoopTimer);
+ }
+ },
+ /**
+ * @private
+ */
+ _computeTopGap() {
+ return 0;
+ },
+ /**
+ * @private
+ */
+ _isShown() {
+ return true;
+ },
+ /**
+ * @private
+ * @param {boolean} [useFixed=true]
+ */
+ _toggleFixedHeader: function (useFixed = true) {
+ this.fixedHeader = useFixed;
+ this._adaptToHeaderChange();
+ this.el.classList.toggle('o_header_affixed', useFixed);
+ this._adaptFixedHeaderPosition();
+ },
+ /**
+ * @private
+ */
+ _updateMainPaddingTop: function () {
+ this.headerHeight = this.$el.outerHeight();
+ this.topGap = this._computeTopGap();
+
+ if (this.isOverlayHeader) {
+ return;
+ }
+ this.$main.css('padding-top', this.fixedHeader ? this.headerHeight : '');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the window is scrolled
+ *
+ * @private
+ * @param {integer} scroll
+ */
+ _updateHeaderOnScroll: function (scroll) {
+ // Disable css transition if refresh with scrollTop > 0
+ if (!this.hasScrolled) {
+ this.hasScrolled = true;
+ if (scroll > 0) {
+ this.$el.addClass('o_header_no_transition');
+ }
+ } else {
+ this.$el.removeClass('o_header_no_transition');
+ }
+
+ // Indicates the page is scrolled, the logo size is changed.
+ const headerIsScrolled = (scroll > this.scrolledPoint);
+ if (this.headerIsScrolled !== headerIsScrolled) {
+ this.el.classList.toggle('o_header_is_scrolled', headerIsScrolled);
+ this.$el.trigger('odoo-transitionstart');
+ this.headerIsScrolled = headerIsScrolled;
+ }
+
+ // Close opened menus
+ this.$dropdowns.removeClass('show');
+ this.$navbarCollapses.removeClass('show').attr('aria-expanded', false);
+ },
+ /**
+ * Called when the window is resized
+ *
+ * @private
+ */
+ _updateHeaderOnResize: function () {
+ this._adaptFixedHeaderPosition();
+ if (document.body.classList.contains('overflow-hidden')
+ && config.device.size_class > config.device.SIZES.SM) {
+ document.body.classList.remove('overflow-hidden');
+ this.$el.find('.navbar-collapse').removeClass('show');
+ }
+ },
+});
+
+publicWidget.registry.StandardAffixedHeader = BaseAnimatedHeader.extend({
+ selector: 'header.o_header_standard:not(.o_header_sidebar)',
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super(...arguments);
+ this.fixedHeaderShow = false;
+ this.scrolledPoint = 300;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.headerHeight = this.$el.outerHeight();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _isShown() {
+ return !this.fixedHeader || this.fixedHeaderShow;
+ },
+ /**
+ * Called when the window is scrolled
+ *
+ * @private
+ * @param {integer} scroll
+ */
+ _updateHeaderOnScroll: function (scroll) {
+ this._super(...arguments);
+
+ const mainPosScrolled = (scroll > this.headerHeight + this.topGap);
+ const reachPosScrolled = (scroll > this.scrolledPoint + this.topGap);
+
+ // Switch between static/fixed position of the header
+ if (this.fixedHeader !== mainPosScrolled) {
+ this.$el.css('transform', mainPosScrolled ? 'translate(0, -100%)' : '');
+ void this.$el[0].offsetWidth; // Force a paint refresh
+ this._toggleFixedHeader(mainPosScrolled);
+ }
+ // Show/hide header
+ if (this.fixedHeaderShow !== reachPosScrolled) {
+ this.$el.css('transform', reachPosScrolled ? `translate(0, -${this.topGap}px)` : 'translate(0, -100%)');
+ this.fixedHeaderShow = reachPosScrolled;
+ this._adaptToHeaderChange();
+ }
+ },
+});
+
+publicWidget.registry.FixedHeader = BaseAnimatedHeader.extend({
+ selector: 'header.o_header_fixed',
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _updateHeaderOnScroll: function (scroll) {
+ this._super(...arguments);
+ // Need to be 'unfixed' when the window is not scrolled so that the
+ // transparent menu option still works.
+ if (scroll > (this.scrolledPoint + this.topGap)) {
+ if (!this.$el.hasClass('o_header_affixed')) {
+ this.$el.css('transform', `translate(0, -${this.topGap}px)`);
+ void this.$el[0].offsetWidth; // Force a paint refresh
+ this._toggleFixedHeader(true);
+ }
+ } else {
+ this._toggleFixedHeader(false);
+ void this.$el[0].offsetWidth; // Force a paint refresh
+ this.$el.css('transform', '');
+ }
+ },
+});
+
+const BaseDisappearingHeader = publicWidget.registry.FixedHeader.extend({
+ /**
+ * @override
+ */
+ init: function () {
+ this._super(...arguments);
+ this.scrollingDownwards = true;
+ this.hiddenHeader = false;
+ this.position = 0;
+ this.atTop = true;
+ this.checkPoint = 0;
+ this.scrollOffsetLimit = 200;
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._showHeader();
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _hideHeader: function () {
+ this.$el.trigger('odoo-transitionstart');
+ },
+ /**
+ * @override
+ */
+ _isShown() {
+ return !this.fixedHeader || !this.hiddenHeader;
+ },
+ /**
+ * @private
+ */
+ _showHeader: function () {
+ this.$el.trigger('odoo-transitionstart');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _updateHeaderOnScroll: function (scroll) {
+ this._super(...arguments);
+
+ const scrollingDownwards = (scroll > this.position);
+ const atTop = (scroll <= 0);
+ if (scrollingDownwards !== this.scrollingDownwards) {
+ this.checkPoint = scroll;
+ }
+
+ this.scrollingDownwards = scrollingDownwards;
+ this.position = scroll;
+ this.atTop = atTop;
+
+ if (scrollingDownwards) {
+ if (!this.hiddenHeader && scroll - this.checkPoint > (this.scrollOffsetLimit + this.topGap)) {
+ this.hiddenHeader = true;
+ this._hideHeader();
+ }
+ } else {
+ if (this.hiddenHeader && scroll - this.checkPoint < -(this.scrollOffsetLimit + this.topGap) / 2) {
+ this.hiddenHeader = false;
+ this._showHeader();
+ }
+ }
+
+ if (atTop && !this.atTop) {
+ // Force reshowing the invisible-on-scroll sections when reaching
+ // the top again
+ this._showHeader();
+ }
+ },
+});
+
+publicWidget.registry.DisappearingHeader = BaseDisappearingHeader.extend({
+ selector: 'header.o_header_disappears',
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _hideHeader: function () {
+ this._super(...arguments);
+ this.$el.css('transform', 'translate(0, -100%)');
+ },
+ /**
+ * @override
+ */
+ _showHeader: function () {
+ this._super(...arguments);
+ this.$el.css('transform', this.atTop ? '' : `translate(0, -${this.topGap}px)`);
+ },
+});
+
+publicWidget.registry.FadeOutHeader = BaseDisappearingHeader.extend({
+ selector: 'header.o_header_fade_out',
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _hideHeader: function () {
+ this._super(...arguments);
+ this.$el.stop(false, true).fadeOut();
+ },
+ /**
+ * @override
+ */
+ _showHeader: function () {
+ this._super(...arguments);
+ this.$el.css('transform', this.atTop ? '' : `translate(0, -${this.topGap}px)`);
+ this.$el.stop(false, true).fadeIn();
+ },
+});
+
+/**
+ * Auto adapt the header layout so that elements are not wrapped on a new line.
+ */
+publicWidget.registry.autohideMenu = publicWidget.Widget.extend({
+ selector: 'header#top',
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+ this.$topMenu = this.$('#top_menu');
+ this.noAutohide = this.$el.is('.o_no_autohide_menu');
+ if (!this.noAutohide) {
+ await wUtils.onceAllImagesLoaded(this.$('.navbar'), this.$('.o_mega_menu, .o_offcanvas_logo_container, .dropdown-menu .o_lang_flag'));
+
+ // The previous code will make sure we wait for images to be fully
+ // loaded before initializing the auto more menu. But in some cases,
+ // it is not enough, we also have to wait for fonts or even extra
+ // scripts. Those will have no impact on the feature in most cases
+ // though, so we will only update the auto more menu at that time,
+ // no wait for it to initialize the feature.
+ var $window = $(window);
+ $window.on('load.autohideMenu', function () {
+ $window.trigger('resize');
+ });
+
+ dom.initAutoMoreMenu(this.$topMenu, {unfoldable: '.divider, .divider ~ li, .o_no_autohide_item'});
+ }
+ this.$topMenu.removeClass('o_menu_loading');
+ this.$topMenu.trigger('menu_loaded');
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ if (!this.noAutohide && this.$topMenu) {
+ $(window).off('.autohideMenu');
+ dom.destroyAutoMoreMenu(this.$topMenu);
+ }
+ },
+});
+
+/**
+ * Note: this works well with the affixMenu... by chance (menuDirection is
+ * called after alphabetically).
+ *
+ * @todo check bootstrap v4: maybe handled automatically now ?
+ */
+publicWidget.registry.menuDirection = publicWidget.Widget.extend({
+ selector: 'header .navbar .nav',
+ disabledInEditableMode: false,
+ events: {
+ 'show.bs.dropdown': '_onDropdownShow',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.defaultAlignment = this.$el.is('.ml-auto, .ml-auto ~ *') ? 'right' : 'left';
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} alignment - either 'left' or 'right'
+ * @param {integer} liOffset
+ * @param {integer} liWidth
+ * @param {integer} menuWidth
+ * @param {integer} pageWidth
+ * @returns {boolean}
+ */
+ _checkOpening: function (alignment, liOffset, liWidth, menuWidth, pageWidth) {
+ if (alignment === 'left') {
+ // Check if ok to open the dropdown to the right (no window overflow)
+ return (liOffset + menuWidth <= pageWidth);
+ } else {
+ // Check if ok to open the dropdown to the left (no window overflow)
+ return (liOffset + liWidth - menuWidth >= 0);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onDropdownShow: function (ev) {
+ var $li = $(ev.target);
+ var $menu = $li.children('.dropdown-menu');
+ var liOffset = $li.offset().left;
+ var liWidth = $li.outerWidth();
+ var menuWidth = $menu.outerWidth();
+ var pageWidth = $('#wrapwrap').outerWidth();
+
+ $menu.removeClass('dropdown-menu-left dropdown-menu-right');
+
+ var alignment = this.defaultAlignment;
+ if ($li.nextAll(':visible').length === 0) {
+ // The dropdown is the last menu item, open to the left
+ alignment = 'right';
+ }
+
+ // If can't open in the current direction because it would overflow the
+ // page, change the direction. But if the other direction would do the
+ // same, change back the direction.
+ for (var i = 0; i < 2; i++) {
+ if (!this._checkOpening(alignment, liOffset, liWidth, menuWidth, pageWidth)) {
+ alignment = (alignment === 'left' ? 'right' : 'left');
+ }
+ }
+
+ $menu.addClass('dropdown-menu-' + alignment);
+ },
+});
+
+publicWidget.registry.hoverableDropdown = animations.Animation.extend({
+ selector: 'header.o_hoverable_dropdown',
+ disabledInEditableMode: false,
+ effects: [{
+ startEvents: 'resize',
+ update: '_dropdownHover',
+ }],
+ events: {
+ 'mouseenter .dropdown': '_onMouseEnter',
+ 'mouseleave .dropdown': '_onMouseLeave',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.$dropdownMenus = this.$el.find('.dropdown-menu');
+ this.$dropdownToggles = this.$el.find('.dropdown-toggle');
+ this._dropdownHover();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _dropdownHover: function () {
+ if (config.device.size_class > config.device.SIZES.SM) {
+ this.$dropdownMenus.css('margin-top', '0');
+ this.$dropdownMenus.css('top', 'unset');
+ } else {
+ this.$dropdownMenus.css('margin-top', '');
+ this.$dropdownMenus.css('top', '');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseEnter: function (ev) {
+ if (config.device.size_class <= config.device.SIZES.SM) {
+ return;
+ }
+
+ const $dropdown = $(ev.currentTarget);
+ $dropdown.addClass('show');
+ $dropdown.find(this.$dropdownToggles).attr('aria-expanded', 'true');
+ $dropdown.find(this.$dropdownMenus).addClass('show');
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseLeave: function (ev) {
+ if (config.device.size_class <= config.device.SIZES.SM) {
+ return;
+ }
+
+ const $dropdown = $(ev.currentTarget);
+ $dropdown.removeClass('show');
+ $dropdown.find(this.$dropdownToggles).attr('aria-expanded', 'false');
+ $dropdown.find(this.$dropdownMenus).removeClass('show');
+ },
+});
+
+publicWidget.registry.HeaderMainCollapse = publicWidget.Widget.extend({
+ selector: 'header#top',
+ events: {
+ 'show.bs.collapse #top_menu_collapse': '_onCollapseShow',
+ 'hidden.bs.collapse #top_menu_collapse': '_onCollapseHidden',
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onCollapseShow() {
+ this.el.classList.add('o_top_menu_collapse_shown');
+ },
+ /**
+ * @private
+ */
+ _onCollapseHidden() {
+ this.el.classList.remove('o_top_menu_collapse_shown');
+ },
+});
+
+return {
+ extraMenuUpdateCallbacks: extraMenuUpdateCallbacks,
+};
+});
diff --git a/addons/website/static/src/js/content/ripple_effect.js b/addons/website/static/src/js/content/ripple_effect.js
new file mode 100644
index 00000000..2e61d5b7
--- /dev/null
+++ b/addons/website/static/src/js/content/ripple_effect.js
@@ -0,0 +1,72 @@
+odoo.define('website.ripple_effect', function (require) {
+'use strict';
+
+const publicWidget = require('web.public.widget');
+
+publicWidget.registry.RippleEffect = publicWidget.Widget.extend({
+ selector: '.btn, .dropdown-toggle, .dropdown-item',
+ events: {
+ 'click': '_onClick',
+ },
+ duration: 350,
+
+ /**
+ * @override
+ */
+ start: async function () {
+ this.diameter = Math.max(this.$el.outerWidth(), this.$el.outerHeight());
+ this.offsetX = this.$el.offset().left;
+ this.offsetY = this.$el.offset().top;
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super(...arguments);
+ if (this.rippleEl) {
+ this.rippleEl.remove();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {boolean} [toggle]
+ */
+ _toggleRippleEffect: function (toggle) {
+ this.el.classList.toggle('o_js_ripple_effect', toggle);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClick: function (ev) {
+ if (!this.rippleEl) {
+ this.rippleEl = document.createElement('span');
+ this.rippleEl.classList.add('o_ripple_item');
+ this.rippleEl.style.animationDuration = `${this.duration}ms`;
+ this.rippleEl.style.width = `${this.diameter}px`;
+ this.rippleEl.style.height = `${this.diameter}px`;
+ this.el.appendChild(this.rippleEl);
+ }
+
+ clearTimeout(this.timeoutID);
+ this._toggleRippleEffect(false);
+
+ this.rippleEl.style.top = `${ev.pageY - this.offsetY - this.diameter / 2}px`;
+ this.rippleEl.style.left = `${ev.pageX - this.offsetX - this.diameter / 2}px`;
+
+ this._toggleRippleEffect(true);
+ this.timeoutID = setTimeout(() => this._toggleRippleEffect(false), this.duration);
+ },
+});
+});
diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js
new file mode 100644
index 00000000..5eb08630
--- /dev/null
+++ b/addons/website/static/src/js/content/snippets.animation.js
@@ -0,0 +1,1092 @@
+odoo.define('website.content.snippets.animation', function (require) {
+'use strict';
+
+/**
+ * Provides a way to start JS code for snippets' initialization and animations.
+ */
+
+var Class = require('web.Class');
+var config = require('web.config');
+var core = require('web.core');
+const dom = require('web.dom');
+var mixins = require('web.mixins');
+var publicWidget = require('web.public.widget');
+var utils = require('web.utils');
+
+var qweb = core.qweb;
+
+// Initialize fallbacks for the use of requestAnimationFrame,
+// cancelAnimationFrame and performance.now()
+window.requestAnimationFrame = window.requestAnimationFrame
+ || window.webkitRequestAnimationFrame
+ || window.mozRequestAnimationFrame
+ || window.msRequestAnimationFrame
+ || window.oRequestAnimationFrame;
+window.cancelAnimationFrame = window.cancelAnimationFrame
+ || window.webkitCancelAnimationFrame
+ || window.mozCancelAnimationFrame
+ || window.msCancelAnimationFrame
+ || window.oCancelAnimationFrame;
+if (!window.performance || !window.performance.now) {
+ window.performance = {
+ now: function () {
+ return Date.now();
+ }
+ };
+}
+
+/**
+ * Add the notion of edit mode to public widgets.
+ */
+publicWidget.Widget.include({
+ /**
+ * Indicates if the widget should not be instantiated in edit. The default
+ * is true, indeed most (all?) defined widgets only want to initialize
+ * events and states which should not be active in edit mode (this is
+ * especially true for non-website widgets).
+ *
+ * @type {boolean}
+ */
+ disabledInEditableMode: true,
+ /**
+ * Acts as @see Widget.events except that the events are only binded if the
+ * Widget instance is instanciated in edit mode. The property is not
+ * considered if @see disabledInEditableMode is false.
+ */
+ edit_events: null,
+ /**
+ * Acts as @see Widget.events except that the events are only binded if the
+ * Widget instance is instanciated in readonly mode. The property only
+ * makes sense if @see disabledInEditableMode is false, you should simply
+ * use @see Widget.events otherwise.
+ */
+ read_events: null,
+
+ /**
+ * Initializes the events that will need to be binded according to the
+ * given mode.
+ *
+ * @constructor
+ * @param {Object} parent
+ * @param {Object} [options]
+ * @param {boolean} [options.editableMode=false]
+ * true if the page is in edition mode
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+
+ this.editableMode = this.options.editableMode || false;
+ var extraEvents = this.editableMode ? this.edit_events : this.read_events;
+ if (extraEvents) {
+ this.events = _.extend({}, this.events || {}, extraEvents);
+ }
+ },
+});
+
+/**
+ * In charge of handling one animation loop using the requestAnimationFrame
+ * feature. This is used by the `Animation` class below and should not be called
+ * directly by an end developer.
+ *
+ * This uses a simple API: it can be started, stopped, played and paused.
+ */
+var AnimationEffect = Class.extend(mixins.ParentedMixin, {
+ /**
+ * @constructor
+ * @param {Object} parent
+ * @param {function} updateCallback - the animation update callback
+ * @param {string} [startEvents=scroll]
+ * space separated list of events which starts the animation loop
+ * @param {jQuery|DOMElement} [$startTarget=window]
+ * the element(s) on which the startEvents are listened
+ * @param {Object} [options]
+ * @param {function} [options.getStateCallback]
+ * a function which returns a value which represents the state of the
+ * animation, i.e. for two same value, no refreshing of the animation
+ * is needed. Can be used for optimization. If the $startTarget is
+ * the window element, this defaults to returning the current
+ * scoll offset of the window or the size of the window for the
+ * scroll and resize events respectively.
+ * @param {string} [options.endEvents]
+ * space separated list of events which pause the animation loop. If
+ * not given, the animation is stopped after a while (if no
+ * startEvents is received again)
+ * @param {jQuery|DOMElement} [options.$endTarget=$startTarget]
+ * the element(s) on which the endEvents are listened
+ */
+ init: function (parent, updateCallback, startEvents, $startTarget, options) {
+ mixins.ParentedMixin.init.call(this);
+ this.setParent(parent);
+
+ options = options || {};
+ this._minFrameTime = 1000 / (options.maxFPS || 100);
+
+ // Initialize the animation startEvents, startTarget, endEvents, endTarget and callbacks
+ this._updateCallback = updateCallback;
+ this.startEvents = startEvents || 'scroll';
+ const mainScrollingElement = $().getScrollingElement()[0];
+ const mainScrollingTarget = mainScrollingElement === document.documentElement ? window : mainScrollingElement;
+ this.$startTarget = $($startTarget ? $startTarget : this.startEvents === 'scroll' ? mainScrollingTarget : window);
+ if (options.getStateCallback) {
+ this._getStateCallback = options.getStateCallback;
+ } else if (this.startEvents === 'scroll' && this.$startTarget[0] === mainScrollingTarget) {
+ const $scrollable = this.$startTarget;
+ this._getStateCallback = function () {
+ return $scrollable.scrollTop();
+ };
+ } else if (this.startEvents === 'resize' && this.$startTarget[0] === window) {
+ this._getStateCallback = function () {
+ return {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ };
+ };
+ } else {
+ this._getStateCallback = function () {
+ return undefined;
+ };
+ }
+ this.endEvents = options.endEvents || false;
+ this.$endTarget = options.$endTarget ? $(options.$endTarget) : this.$startTarget;
+
+ this._updateCallback = this._updateCallback.bind(parent);
+ this._getStateCallback = this._getStateCallback.bind(parent);
+
+ // Add a namespace to events using the generated uid
+ this._uid = '_animationEffect' + _.uniqueId();
+ this.startEvents = _processEvents(this.startEvents, this._uid);
+ if (this.endEvents) {
+ this.endEvents = _processEvents(this.endEvents, this._uid);
+ }
+
+ function _processEvents(events, namespace) {
+ events = events.split(' ');
+ return _.each(events, function (e, index) {
+ events[index] += ('.' + namespace);
+ }).join(' ');
+ }
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ mixins.ParentedMixin.destroy.call(this);
+ this.stop();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Initializes when the animation must be played and paused and initializes
+ * the animation first frame.
+ */
+ start: function () {
+ // Initialize the animation first frame
+ this._paused = false;
+ this._rafID = window.requestAnimationFrame((function (t) {
+ this._update(t);
+ this._paused = true;
+ }).bind(this));
+
+ // Initialize the animation play/pause events
+ if (this.endEvents) {
+ /**
+ * If there are endEvents, the animation should begin playing when
+ * the startEvents are triggered on the $startTarget and pause when
+ * the endEvents are triggered on the $endTarget.
+ */
+ this.$startTarget.on(this.startEvents, (function (e) {
+ if (this._paused) {
+ _.defer(this.play.bind(this, e));
+ }
+ }).bind(this));
+ this.$endTarget.on(this.endEvents, (function () {
+ if (!this._paused) {
+ _.defer(this.pause.bind(this));
+ }
+ }).bind(this));
+ } else {
+ /**
+ * Else, if there is no endEvents, the animation should begin playing
+ * when the startEvents are *continuously* triggered on the
+ * $startTarget or fully played once. To achieve this, the animation
+ * begins playing and is scheduled to pause after 2 seconds. If the
+ * startEvents are triggered during that time, this is not paused
+ * for another 2 seconds. This allows to describe an "effect"
+ * animation (which lasts less than 2 seconds) or an animation which
+ * must be playing *during* an event (scroll, mousemove, resize,
+ * repeated clicks, ...).
+ */
+ var pauseTimer = null;
+ this.$startTarget.on(this.startEvents, _.throttle((function (e) {
+ this.play(e);
+
+ clearTimeout(pauseTimer);
+ pauseTimer = _.delay((function () {
+ this.pause();
+ pauseTimer = null;
+ }).bind(this), 2000);
+ }).bind(this), 250, {trailing: false}));
+ }
+ },
+ /**
+ * Pauses the animation and destroys the attached events which trigger the
+ * animation to be played or paused.
+ */
+ stop: function () {
+ this.$startTarget.off(this.startEvents);
+ if (this.endEvents) {
+ this.$endTarget.off(this.endEvents);
+ }
+ this.pause();
+ },
+ /**
+ * Forces the requestAnimationFrame loop to start.
+ *
+ * @param {Event} e - the event which triggered the animation to play
+ */
+ play: function (e) {
+ this._newEvent = e;
+ if (!this._paused) {
+ return;
+ }
+ this._paused = false;
+ this._rafID = window.requestAnimationFrame(this._update.bind(this));
+ this._lastUpdateTimestamp = undefined;
+ },
+ /**
+ * Forces the requestAnimationFrame loop to stop.
+ */
+ pause: function () {
+ if (this._paused) {
+ return;
+ }
+ this._paused = true;
+ window.cancelAnimationFrame(this._rafID);
+ this._lastUpdateTimestamp = undefined;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Callback which is repeatedly called by the requestAnimationFrame loop.
+ * It controls the max fps at which the animation is running and initializes
+ * the values that the update callback needs to describe the animation
+ * (state, elapsedTime, triggered event).
+ *
+ * @private
+ * @param {DOMHighResTimeStamp} timestamp
+ */
+ _update: function (timestamp) {
+ if (this._paused) {
+ return;
+ }
+ this._rafID = window.requestAnimationFrame(this._update.bind(this));
+
+ // Check the elapsed time since the last update callback call.
+ // Consider it 0 if there is no info of last timestamp and leave this
+ // _update call if it was called too soon (would overflow the set max FPS).
+ var elapsedTime = 0;
+ if (this._lastUpdateTimestamp) {
+ elapsedTime = timestamp - this._lastUpdateTimestamp;
+ if (elapsedTime < this._minFrameTime) {
+ return;
+ }
+ }
+
+ // Check the new animation state thanks to the get state callback and
+ // store its new value. If the state is the same as the previous one,
+ // leave this _update call, except if there is an event which triggered
+ // the "play" method again.
+ var animationState = this._getStateCallback(elapsedTime, this._newEvent);
+ if (!this._newEvent
+ && animationState !== undefined
+ && _.isEqual(animationState, this._animationLastState)) {
+ return;
+ }
+ this._animationLastState = animationState;
+
+ // Call the update callback with frame parameters
+ this._updateCallback(this._animationLastState, elapsedTime, this._newEvent);
+ this._lastUpdateTimestamp = timestamp; // Save the timestamp at which the update callback was really called
+ this._newEvent = undefined; // Forget the event which triggered the last "play" call
+ },
+});
+
+/**
+ * Also register AnimationEffect automatically (@see effects, _prepareEffects).
+ */
+var Animation = publicWidget.Widget.extend({
+ /**
+ * The max FPS at which all the automatic animation effects will be
+ * running by default.
+ */
+ maxFPS: 100,
+ /**
+ * @see this._prepareEffects
+ *
+ * @type {Object[]}
+ * @type {string} startEvents
+ * The names of the events which trigger the effect to begin playing.
+ * @type {string} [startTarget]
+ * A selector to find the target where to listen for the start events
+ * (if no selector, the window target will be used). If the whole
+ * $target of the animation should be used, use the 'selector' string.
+ * @type {string} [endEvents]
+ * The name of the events which trigger the end of the effect (if none
+ * is defined, the animation will stop after a while
+ * @see AnimationEffect.start).
+ * @type {string} [endTarget]
+ * A selector to find the target where to listen for the end events
+ * (if no selector, the startTarget will be used). If the whole
+ * $target of the animation should be used, use the 'selector' string.
+ * @type {string} update
+ * A string which refers to a method which will be used as the update
+ * callback for the effect. It receives 3 arguments: the animation
+ * state, the elapsedTime since last update and the event which
+ * triggered the animation (undefined if just a new update call
+ * without trigger).
+ * @type {string} [getState]
+ * The animation state is undefined by default, the scroll offset for
+ * the particular {startEvents: 'scroll'} effect and an object with
+ * width and height for the particular {startEvents: 'resize'} effect.
+ * There is the possibility to define the getState callback of the
+ * animation effect with this key. This allows to improve performance
+ * even further in some cases.
+ */
+ effects: [],
+
+ /**
+ * Initializes the animation. The method should not be called directly as
+ * called automatically on animation instantiation and on restart.
+ *
+ * Also, prepares animation's effects and start them if any.
+ *
+ * @override
+ */
+ start: function () {
+ this._prepareEffects();
+ _.each(this._animationEffects, function (effect) {
+ effect.start();
+ });
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Registers `AnimationEffect` instances.
+ *
+ * This can be done by extending this method and calling the @see _addEffect
+ * method in it or, better, by filling the @see effects property.
+ *
+ * @private
+ */
+ _prepareEffects: function () {
+ this._animationEffects = [];
+
+ var self = this;
+ _.each(this.effects, function (desc) {
+ self._addEffect(self[desc.update], desc.startEvents, _findTarget(desc.startTarget), {
+ getStateCallback: desc.getState && self[desc.getState],
+ endEvents: desc.endEvents || undefined,
+ $endTarget: _findTarget(desc.endTarget),
+ maxFPS: self.maxFPS,
+ });
+
+ // Return the DOM element matching the selector in the form
+ // described above.
+ function _findTarget(selector) {
+ if (selector) {
+ if (selector === 'selector') {
+ return self.$target;
+ }
+ return self.$(selector);
+ }
+ return undefined;
+ }
+ });
+ },
+ /**
+ * Registers a new `AnimationEffect` according to given parameters.
+ *
+ * @private
+ * @see AnimationEffect.init
+ */
+ _addEffect: function (updateCallback, startEvents, $startTarget, options) {
+ this._animationEffects.push(
+ new AnimationEffect(this, updateCallback, startEvents, $startTarget, options)
+ );
+ },
+});
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+var registry = publicWidget.registry;
+
+registry.slider = publicWidget.Widget.extend({
+ selector: '.carousel',
+ disabledInEditableMode: false,
+ edit_events: {
+ 'content_changed': '_onContentChanged',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.$('img').on('load.slider', () => this._computeHeights());
+ this._computeHeights();
+ // Initialize carousel and pause if in edit mode.
+ this.$target.carousel(this.editableMode ? 'pause' : undefined);
+ $(window).on('resize.slider', _.debounce(() => this._computeHeights(), 250));
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ this.$('img').off('.slider');
+ this.$target.carousel('pause');
+ this.$target.removeData('bs.carousel');
+ _.each(this.$('.carousel-item'), function (el) {
+ $(el).css('min-height', '');
+ });
+ $(window).off('.slider');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _computeHeights: function () {
+ var maxHeight = 0;
+ var $items = this.$('.carousel-item');
+ $items.css('min-height', '');
+ _.each($items, function (el) {
+ var $item = $(el);
+ var isActive = $item.hasClass('active');
+ $item.addClass('active');
+ var height = $item.outerHeight();
+ if (height > maxHeight) {
+ maxHeight = height;
+ }
+ $item.toggleClass('active', isActive);
+ });
+ $items.css('min-height', maxHeight);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onContentChanged: function (ev) {
+ this._computeHeights();
+ },
+});
+
+registry.Parallax = Animation.extend({
+ selector: '.parallax',
+ disabledInEditableMode: false,
+ effects: [{
+ startEvents: 'scroll',
+ update: '_onWindowScroll',
+ }],
+
+ /**
+ * @override
+ */
+ start: function () {
+ this._rebuild();
+ $(window).on('resize.animation_parallax', _.debounce(this._rebuild.bind(this), 500));
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ $(window).off('.animation_parallax');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Prepares the background element which will scroll at a different speed
+ * according to the viewport dimensions and other snippet parameters.
+ *
+ * @private
+ */
+ _rebuild: function () {
+ // Add/find bg DOM element to hold the parallax bg (support old v10.0 parallax)
+ this.$bg = this.$('> .s_parallax_bg');
+
+ // Get parallax speed
+ this.speed = parseFloat(this.$target.attr('data-scroll-background-ratio') || 0);
+
+ // Reset offset if parallax effect will not be performed and leave
+ var noParallaxSpeed = (this.speed === 0 || this.speed === 1);
+ if (noParallaxSpeed) {
+ this.$bg.css({
+ transform: '',
+ top: '',
+ bottom: '',
+ });
+ return;
+ }
+
+ // Initialize parallax data according to snippet and viewport dimensions
+ this.viewport = document.body.clientHeight - $('#wrapwrap').position().top;
+ this.visibleArea = [this.$target.offset().top];
+ this.visibleArea.push(this.visibleArea[0] + this.$target.innerHeight() + this.viewport);
+ this.ratio = this.speed * (this.viewport / 10);
+
+ // Provide a "safe-area" to limit parallax
+ const absoluteRatio = Math.abs(this.ratio);
+ this.$bg.css({
+ top: -absoluteRatio,
+ bottom: -absoluteRatio,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Effects
+ //--------------------------------------------------------------------------
+
+ /**
+ * Describes how to update the snippet when the window scrolls.
+ *
+ * @private
+ * @param {integer} scrollOffset
+ */
+ _onWindowScroll: function (scrollOffset) {
+ // Speed == 0 is no effect and speed == 1 is handled by CSS only
+ if (this.speed === 0 || this.speed === 1) {
+ return;
+ }
+
+ // Perform translation if the element is visible only
+ var vpEndOffset = scrollOffset + this.viewport;
+ if (vpEndOffset >= this.visibleArea[0]
+ && vpEndOffset <= this.visibleArea[1]) {
+ this.$bg.css('transform', 'translateY(' + _getNormalizedPosition.call(this, vpEndOffset) + 'px)');
+ }
+
+ function _getNormalizedPosition(pos) {
+ // Normalize scroll in a 1 to 0 range
+ var r = (pos - this.visibleArea[1]) / (this.visibleArea[0] - this.visibleArea[1]);
+ // Normalize accordingly to current options
+ return Math.round(this.ratio * (2 * r - 1));
+ }
+ },
+});
+
+registry.mediaVideo = publicWidget.Widget.extend({
+ selector: '.media_iframe_video',
+
+ /**
+ * @override
+ */
+ start: function () {
+ // TODO: this code should be refactored to make more sense and be better
+ // integrated with Odoo (this refactoring should be done in master).
+
+ var def = this._super.apply(this, arguments);
+ if (this.$target.children('iframe').length) {
+ // There already is an <iframe/>, do nothing
+ return def;
+ }
+
+ // Bug fix / compatibility: empty the <div/> element as all information
+ // to rebuild the iframe should have been saved on the <div/> element
+ this.$target.empty();
+
+ // Add extra content for size / edition
+ this.$target.append(
+ '<div class="css_editable_mode_display">&nbsp;</div>' +
+ '<div class="media_iframe_video_size">&nbsp;</div>'
+ );
+
+ // Rebuild the iframe. Depending on version / compatibility / instance,
+ // the src is saved in the 'data-src' attribute or the
+ // 'data-oe-expression' one (the latter is used as a workaround in 10.0
+ // system but should obviously be reviewed in master).
+ this.$target.append($('<iframe/>', {
+ src: _.escape(this.$target.data('oe-expression') || this.$target.data('src')),
+ frameborder: '0',
+ allowfullscreen: 'allowfullscreen',
+ sandbox: 'allow-scripts allow-same-origin', // https://www.html5rocks.com/en/tutorials/security/sandboxed-iframes/
+ }));
+
+ return def;
+ },
+});
+
+registry.backgroundVideo = publicWidget.Widget.extend({
+ selector: '.o_background_video',
+ xmlDependencies: ['/website/static/src/xml/website.background.video.xml'],
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ start: function () {
+ var proms = [this._super(...arguments)];
+
+ this.videoSrc = this.el.dataset.bgVideoSrc;
+ this.iframeID = _.uniqueId('o_bg_video_iframe_');
+
+ this.isYoutubeVideo = this.videoSrc.indexOf('youtube') >= 0;
+ this.isMobileEnv = config.device.size_class <= config.device.SIZES.LG && config.device.touch;
+ if (this.isYoutubeVideo && this.isMobileEnv) {
+ this.videoSrc = this.videoSrc + "&enablejsapi=1";
+
+ if (!window.YT) {
+ var oldOnYoutubeIframeAPIReady = window.onYouTubeIframeAPIReady;
+ proms.push(new Promise(resolve => {
+ window.onYouTubeIframeAPIReady = () => {
+ if (oldOnYoutubeIframeAPIReady) {
+ oldOnYoutubeIframeAPIReady();
+ }
+ return resolve();
+ };
+ }));
+ $('<script/>', {
+ src: 'https://www.youtube.com/iframe_api',
+ }).appendTo('head');
+ }
+ }
+
+ var throttledUpdate = _.throttle(() => this._adjustIframe(), 50);
+
+ var $dropdownMenu = this.$el.closest('.dropdown-menu');
+ if ($dropdownMenu.length) {
+ this.$dropdownParent = $dropdownMenu.parent();
+ this.$dropdownParent.on('shown.bs.dropdown.backgroundVideo', throttledUpdate);
+ }
+
+ $(window).on('resize.' + this.iframeID, throttledUpdate);
+
+ return Promise.all(proms).then(() => this._appendBgVideo());
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+
+ if (this.$dropdownParent) {
+ this.$dropdownParent.off('.backgroundVideo');
+ }
+
+ $(window).off('resize.' + this.iframeID);
+
+ if (this.$bgVideoContainer) {
+ this.$bgVideoContainer.remove();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adjusts iframe sizes and position so that it fills the container and so
+ * that it is centered in it.
+ *
+ * @private
+ */
+ _adjustIframe: function () {
+ if (!this.$iframe) {
+ return;
+ }
+
+ this.$iframe.removeClass('show');
+
+ // Adjust the iframe
+ var wrapperWidth = this.$target.innerWidth();
+ var wrapperHeight = this.$target.innerHeight();
+ var relativeRatio = (wrapperWidth / wrapperHeight) / (16 / 9);
+ var style = {};
+ if (relativeRatio >= 1.0) {
+ style['width'] = '100%';
+ style['height'] = (relativeRatio * 100) + '%';
+ style['left'] = '0';
+ style['top'] = (-(relativeRatio - 1.0) / 2 * 100) + '%';
+ } else {
+ style['width'] = ((1 / relativeRatio) * 100) + '%';
+ style['height'] = '100%';
+ style['left'] = (-((1 / relativeRatio) - 1.0) / 2 * 100) + '%';
+ style['top'] = '0';
+ }
+ this.$iframe.css(style);
+
+ void this.$iframe[0].offsetWidth; // Force style addition
+ this.$iframe.addClass('show');
+ },
+ /**
+ * Append background video related elements to the target.
+ *
+ * @private
+ */
+ _appendBgVideo: function () {
+ var $oldContainer = this.$bgVideoContainer || this.$('> .o_bg_video_container');
+ this.$bgVideoContainer = $(qweb.render('website.background.video', {
+ videoSrc: this.videoSrc,
+ iframeID: this.iframeID,
+ }));
+ this.$iframe = this.$bgVideoContainer.find('.o_bg_video_iframe');
+ this.$iframe.one('load', () => {
+ this.$bgVideoContainer.find('.o_bg_video_loading').remove();
+ });
+ this.$bgVideoContainer.prependTo(this.$target);
+ $oldContainer.remove();
+
+ this._adjustIframe();
+
+ // YouTube does not allow to auto-play video in mobile devices, so we
+ // have to play the video manually.
+ if (this.isMobileEnv && this.isYoutubeVideo) {
+ new window.YT.Player(this.iframeID, {
+ events: {
+ onReady: ev => ev.target.playVideo(),
+ }
+ });
+ }
+ },
+});
+
+registry.socialShare = publicWidget.Widget.extend({
+ selector: '.oe_social_share',
+ xmlDependencies: ['/website/static/src/xml/website.share.xml'],
+ events: {
+ 'mouseenter': '_onMouseEnter',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _bindSocialEvent: function () {
+ this.$('.oe_social_facebook').click($.proxy(this._renderSocial, this, 'facebook'));
+ this.$('.oe_social_twitter').click($.proxy(this._renderSocial, this, 'twitter'));
+ this.$('.oe_social_linkedin').click($.proxy(this._renderSocial, this, 'linkedin'));
+ },
+ /**
+ * @private
+ */
+ _render: function () {
+ this.$el.popover({
+ content: qweb.render('website.social_hover', {medias: this.socialList}),
+ placement: 'bottom',
+ container: this.$el,
+ html: true,
+ trigger: 'manual',
+ animation: false,
+ }).popover("show");
+
+ this.$el.off('mouseleave.socialShare').on('mouseleave.socialShare', function () {
+ var self = this;
+ setTimeout(function () {
+ if (!$(".popover:hover").length) {
+ $(self).popover('dispose');
+ }
+ }, 200);
+ });
+ },
+ /**
+ * @private
+ */
+ _renderSocial: function (social) {
+ var url = this.$el.data('urlshare') || document.URL.split(/[?#]/)[0];
+ url = encodeURIComponent(url);
+ var title = document.title.split(" | ")[0]; // get the page title without the company name
+ var hashtags = ' #' + document.title.split(" | ")[1].replace(' ', '') + ' ' + this.hashtags; // company name without spaces (for hashtag)
+ var socialNetworks = {
+ 'facebook': 'https://www.facebook.com/sharer/sharer.php?u=' + url,
+ 'twitter': 'https://twitter.com/intent/tweet?original_referer=' + url + '&text=' + encodeURIComponent(title + hashtags + ' - ') + url,
+ 'linkedin': 'https://www.linkedin.com/sharing/share-offsite/?url=' + url,
+ };
+ if (!_.contains(_.keys(socialNetworks), social)) {
+ return;
+ }
+ var wHeight = 500;
+ var wWidth = 500;
+ window.open(socialNetworks[social], '', 'menubar=no, toolbar=no, resizable=yes, scrollbar=yes, height=' + wHeight + ',width=' + wWidth);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the user hovers the animation element -> open the social
+ * links popover.
+ *
+ * @private
+ */
+ _onMouseEnter: function () {
+ var social = this.$el.data('social');
+ this.socialList = social ? social.split(',') : ['facebook', 'twitter', 'linkedin'];
+ this.hashtags = this.$el.data('hashtags') || '';
+
+ this._render();
+ this._bindSocialEvent();
+ },
+});
+
+registry.anchorSlide = publicWidget.Widget.extend({
+ selector: 'a[href^="/"][href*="#"], a[href^="#"]',
+ events: {
+ 'click': '_onAnimateClick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {jQuery} $el the element to scroll to.
+ * @param {string} [scrollValue='true'] scroll value
+ * @returns {Promise}
+ */
+ async _scrollTo($el, scrollValue = 'true') {
+ return dom.scrollTo($el[0], {
+ duration: scrollValue === 'true' ? 500 : 0,
+ extraOffset: this._computeExtraOffset(),
+ });
+ },
+ /**
+ * @private
+ */
+ _computeExtraOffset() {
+ return 0;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAnimateClick: function (ev) {
+ if (this.$target[0].pathname !== window.location.pathname) {
+ return;
+ }
+ var hash = this.$target[0].hash;
+ if (!utils.isValidAnchor(hash)) {
+ return;
+ }
+ var $anchor = $(hash);
+ const scrollValue = $anchor.attr('data-anchor');
+ if (!$anchor.length || !scrollValue) {
+ return;
+ }
+ ev.preventDefault();
+ this._scrollTo($anchor, scrollValue);
+ },
+});
+
+registry.FullScreenHeight = publicWidget.Widget.extend({
+ selector: '.o_full_screen_height',
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ start() {
+ if (this.$el.outerHeight() > this._computeIdealHeight()) {
+ // Only initialize if taller than the ideal height as some extra css
+ // rules may alter the full-screen-height class behavior in some
+ // cases (blog...).
+ this._adaptSize();
+ $(window).on('resize.FullScreenHeight', _.debounce(() => this._adaptSize(), 250));
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ $(window).off('.FullScreenHeight');
+ this.el.style.setProperty('min-height', '');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adaptSize() {
+ const height = this._computeIdealHeight();
+ this.el.style.setProperty('min-height', `${height}px`, 'important');
+ },
+ /**
+ * @private
+ */
+ _computeIdealHeight() {
+ const windowHeight = $(window).outerHeight();
+ // Doing it that way allows to considerer fixed headers, hidden headers,
+ // connected users, ...
+ const firstContentEl = $('#wrapwrap > main > :first-child')[0]; // first child to consider the padding-top of main
+ const mainTopPos = firstContentEl.getBoundingClientRect().top + dom.closestScrollable(firstContentEl.parentNode).scrollTop;
+ return (windowHeight - mainTopPos);
+ },
+});
+
+registry.ScrollButton = registry.anchorSlide.extend({
+ selector: '.o_scroll_button',
+
+ /**
+ * @override
+ */
+ _onAnimateClick: function (ev) {
+ ev.preventDefault();
+ const $nextElement = this.$el.closest('section').next();
+ if ($nextElement.length) {
+ this._scrollTo($nextElement);
+ }
+ },
+});
+
+registry.FooterSlideout = publicWidget.Widget.extend({
+ selector: '#wrapwrap:has(.o_footer_slideout)',
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ async start() {
+ const $main = this.$('> main');
+ const slideoutEffect = $main.outerHeight() >= $(window).outerHeight();
+ this.el.classList.toggle('o_footer_effect_enable', slideoutEffect);
+
+ // Add a pixel div over the footer, after in the DOM, so that the
+ // height of the footer is understood by Firefox sticky implementation
+ // (which it seems to not understand because of the combination of 3
+ // items: the footer is the last :visible element in the #wrapwrap, the
+ // #wrapwrap uses flex layout and the #wrapwrap is the element with a
+ // scrollbar).
+ // TODO check if the hack is still needed by future browsers.
+ this.__pixelEl = document.createElement('div');
+ this.__pixelEl.style.width = `1px`;
+ this.__pixelEl.style.height = `1px`;
+ this.__pixelEl.style.marginTop = `-1px`;
+ this.el.appendChild(this.__pixelEl);
+
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ this.el.classList.remove('o_footer_effect_enable');
+ this.__pixelEl.remove();
+ },
+});
+
+registry.HeaderHamburgerFull = publicWidget.Widget.extend({
+ selector: 'header:has(.o_header_hamburger_full_toggler):not(:has(.o_offcanvas_menu_toggler))',
+ events: {
+ 'click .o_header_hamburger_full_toggler': '_onToggleClick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onToggleClick() {
+ document.body.classList.add('overflow-hidden');
+ setTimeout(() => $(window).trigger('scroll'), 100);
+ },
+});
+
+registry.BottomFixedElement = publicWidget.Widget.extend({
+ selector: '#wrapwrap',
+
+ /**
+ * @override
+ */
+ async start() {
+ this.$scrollingElement = $().getScrollingElement();
+ this.__hideBottomFixedElements = _.debounce(() => this._hideBottomFixedElements(), 500);
+ this.$scrollingElement.on('scroll.bottom_fixed_element', this.__hideBottomFixedElements);
+ $(window).on('resize.bottom_fixed_element', this.__hideBottomFixedElements);
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ this.$scrollingElement.off('.bottom_fixed_element');
+ $(window).off('.bottom_fixed_element');
+ $('.o_bottom_fixed_element').removeClass('o_bottom_fixed_element_hidden');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Hides the elements that are fixed at the bottom of the screen if the
+ * scroll reaches the bottom of the page and if the elements hide a button.
+ *
+ * @private
+ */
+ _hideBottomFixedElements() {
+ // Note: check in the whole DOM instead of #wrapwrap as unfortunately
+ // some things are still put outside of the #wrapwrap (like the livechat
+ // button which is the main reason of this code).
+ const $bottomFixedElements = $('.o_bottom_fixed_element');
+ if (!$bottomFixedElements.length) {
+ return;
+ }
+
+ $bottomFixedElements.removeClass('o_bottom_fixed_element_hidden');
+ if ((this.$scrollingElement[0].offsetHeight + this.$scrollingElement[0].scrollTop) >= (this.$scrollingElement[0].scrollHeight - 2)) {
+ const buttonEls = [...this.$('.btn:visible')];
+ for (const el of $bottomFixedElements) {
+ if (buttonEls.some(button => dom.areColliding(button, el))) {
+ el.classList.add('o_bottom_fixed_element_hidden');
+ }
+ }
+ }
+ },
+});
+
+return {
+ Widget: publicWidget.Widget,
+ Animation: Animation,
+ registry: registry,
+
+ Class: Animation, // Deprecated
+};
+});
diff --git a/addons/website/static/src/js/content/website_root.js b/addons/website/static/src/js/content/website_root.js
new file mode 100644
index 00000000..c2844a49
--- /dev/null
+++ b/addons/website/static/src/js/content/website_root.js
@@ -0,0 +1,350 @@
+odoo.define('website.root', function (require) {
+'use strict';
+
+const ajax = require('web.ajax');
+const {_t} = require('web.core');
+var Dialog = require('web.Dialog');
+const KeyboardNavigationMixin = require('web.KeyboardNavigationMixin');
+const session = require('web.session');
+var publicRootData = require('web.public.root');
+require("web.zoomodoo");
+
+var websiteRootRegistry = publicRootData.publicRootRegistry;
+
+var WebsiteRoot = publicRootData.PublicRoot.extend(KeyboardNavigationMixin, {
+ events: _.extend({}, KeyboardNavigationMixin.events, publicRootData.PublicRoot.prototype.events || {}, {
+ 'click .js_change_lang': '_onLangChangeClick',
+ 'click .js_publish_management .js_publish_btn': '_onPublishBtnClick',
+ 'click .js_multi_website_switch': '_onWebsiteSwitch',
+ 'shown.bs.modal': '_onModalShown',
+ }),
+ custom_events: _.extend({}, publicRootData.PublicRoot.prototype.custom_events || {}, {
+ 'gmap_api_request': '_onGMapAPIRequest',
+ 'gmap_api_key_request': '_onGMapAPIKeyRequest',
+ 'ready_to_clean_for_save': '_onWidgetsStopRequest',
+ 'seo_object_request': '_onSeoObjectRequest',
+ }),
+
+ /**
+ * @override
+ */
+ init() {
+ this.isFullscreen = false;
+ KeyboardNavigationMixin.init.call(this, {
+ autoAccessKeys: false,
+ });
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ KeyboardNavigationMixin.start.call(this);
+ // Compatibility lang change ?
+ if (!this.$('.js_change_lang').length) {
+ var $links = this.$('.js_language_selector a:not([data-oe-id])');
+ var m = $(_.min($links, function (l) {
+ return $(l).attr('href').length;
+ })).attr('href');
+ $links.each(function () {
+ var $link = $(this);
+ var t = $link.attr('href');
+ var l = (t === m) ? "default" : t.split('/')[1];
+ $link.data('lang', l).addClass('js_change_lang');
+ });
+ }
+
+ // Enable magnify on zommable img
+ this.$('.zoomable img[data-zoom]').zoomOdoo();
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ KeyboardNavigationMixin.destroy.call(this);
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getContext: function (context) {
+ var html = document.documentElement;
+ return _.extend({
+ 'website_id': html.getAttribute('data-website-id') | 0,
+ }, this._super.apply(this, arguments));
+ },
+ /**
+ * @override
+ */
+ _getExtraContext: function (context) {
+ var html = document.documentElement;
+ return _.extend({
+ 'editable': !!(html.dataset.editable || $('[data-oe-model]').length), // temporary hack, this should be done in python
+ 'translatable': !!html.dataset.translatable,
+ 'edit_translations': !!html.dataset.edit_translations,
+ }, this._super.apply(this, arguments));
+ },
+ /**
+ * @private
+ * @param {boolean} [refetch=false]
+ */
+ async _getGMapAPIKey(refetch) {
+ if (refetch || !this._gmapAPIKeyProm) {
+ this._gmapAPIKeyProm = new Promise(async resolve => {
+ const data = await this._rpc({
+ route: '/website/google_maps_api_key',
+ });
+ resolve(JSON.parse(data).google_maps_api_key || '');
+ });
+ }
+ return this._gmapAPIKeyProm;
+ },
+ /**
+ * @override
+ */
+ _getPublicWidgetsRegistry: function (options) {
+ var registry = this._super.apply(this, arguments);
+ if (options.editableMode) {
+ return _.pick(registry, function (PublicWidget) {
+ return !PublicWidget.prototype.disabledInEditableMode;
+ });
+ }
+ return registry;
+ },
+ /**
+ * @private
+ * @param {boolean} [editableMode=false]
+ * @param {boolean} [refetch=false]
+ */
+ async _loadGMapAPI(editableMode, refetch) {
+ // Note: only need refetch to reload a configured key and load the
+ // library. If the library was loaded with a correct key and that the
+ // key changes meanwhile... it will not work but we can agree the user
+ // can bother to reload the page at that moment.
+ if (refetch || !this._gmapAPILoading) {
+ this._gmapAPILoading = new Promise(async resolve => {
+ const key = await this._getGMapAPIKey(refetch);
+
+ window.odoo_gmap_api_post_load = (async function odoo_gmap_api_post_load() {
+ await this._startWidgets(undefined, {editableMode: editableMode});
+ resolve(key);
+ }).bind(this);
+
+ if (!key) {
+ if (!editableMode && session.is_admin) {
+ this.displayNotification({
+ type: 'warning',
+ sticky: true,
+ message:
+ $('<div/>').append(
+ $('<span/>', {text: _t("Cannot load google map.")}),
+ $('<br/>'),
+ $('<a/>', {
+ href: "/web#action=website.action_website_configuration",
+ text: _t("Check your configuration."),
+ }),
+ )[0].outerHTML,
+ });
+ }
+ resolve(false);
+ this._gmapAPILoading = false;
+ return;
+ }
+ await ajax.loadJS(`https://maps.googleapis.com/maps/api/js?v=3.exp&libraries=places&callback=odoo_gmap_api_post_load&key=${key}`);
+ });
+ }
+ return this._gmapAPILoading;
+ },
+ /**
+ * Toggles the fullscreen mode.
+ *
+ * @private
+ * @param {boolean} state toggle fullscreen on/off (true/false)
+ */
+ _toggleFullscreen(state) {
+ this.isFullscreen = state;
+ document.body.classList.add('o_fullscreen_transition');
+ document.body.classList.toggle('o_fullscreen', this.isFullscreen);
+ document.body.style.overflowX = 'hidden';
+ let resizing = true;
+ window.requestAnimationFrame(function resizeFunction() {
+ window.dispatchEvent(new Event('resize'));
+ if (resizing) {
+ window.requestAnimationFrame(resizeFunction);
+ }
+ });
+ let stopResizing;
+ const onTransitionEnd = ev => {
+ if (ev.target === document.body && ev.propertyName === 'padding-top') {
+ stopResizing();
+ }
+ };
+ stopResizing = () => {
+ resizing = false;
+ document.body.style.overflowX = '';
+ document.body.removeEventListener('transitionend', onTransitionEnd);
+ document.body.classList.remove('o_fullscreen_transition');
+ };
+ document.body.addEventListener('transitionend', onTransitionEnd);
+ // Safeguard in case the transitionend event doesn't trigger for whatever reason.
+ window.setTimeout(() => stopResizing(), 500);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onWidgetsStartRequest: function (ev) {
+ ev.data.options = _.clone(ev.data.options || {});
+ ev.data.options.editableMode = ev.data.editableMode;
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @todo review
+ * @private
+ */
+ _onLangChangeClick: function (ev) {
+ ev.preventDefault();
+
+ var $target = $(ev.currentTarget);
+ // retrieve the hash before the redirect
+ var redirect = {
+ lang: $target.data('url_code'),
+ url: encodeURIComponent($target.attr('href').replace(/[&?]edit_translations[^&?]+/, '')),
+ hash: encodeURIComponent(window.location.hash)
+ };
+ window.location.href = _.str.sprintf("/website/lang/%(lang)s?r=%(url)s%(hash)s", redirect);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ async _onGMapAPIRequest(ev) {
+ ev.stopPropagation();
+ const apiKey = await this._loadGMapAPI(ev.data.editableMode, ev.data.refetch);
+ ev.data.onSuccess(apiKey);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ async _onGMapAPIKeyRequest(ev) {
+ ev.stopPropagation();
+ const apiKey = await this._getGMapAPIKey(ev.data.refetch);
+ ev.data.onSuccess(apiKey);
+ },
+ /**
+ /**
+ * Checks information about the page SEO object.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSeoObjectRequest: function (ev) {
+ var res = this._unslugHtmlDataObject('seo-object');
+ ev.data.callback(res);
+ },
+ /**
+ * Returns a model/id object constructed from html data attribute.
+ *
+ * @private
+ * @param {string} dataAttr
+ * @returns {Object} an object with 2 keys: model and id, or null
+ * if not found
+ */
+ _unslugHtmlDataObject: function (dataAttr) {
+ var repr = $('html').data(dataAttr);
+ var match = repr && repr.match(/(.+)\((\d+),(.*)\)/);
+ if (!match) {
+ return null;
+ }
+ return {
+ model: match[1],
+ id: match[2] | 0,
+ };
+ },
+ /**
+ * @todo review
+ * @private
+ */
+ _onPublishBtnClick: function (ev) {
+ ev.preventDefault();
+ if (document.body.classList.contains('editor_enable')) {
+ return;
+ }
+
+ var self = this;
+ var $data = $(ev.currentTarget).parents(".js_publish_management:first");
+ this._rpc({
+ route: $data.data('controller') || '/website/publish',
+ params: {
+ id: +$data.data('id'),
+ object: $data.data('object'),
+ },
+ })
+ .then(function (result) {
+ $data.toggleClass("css_unpublished css_published");
+ $data.find('input').prop("checked", result);
+ $data.parents("[data-publish]").attr("data-publish", +result ? 'on' : 'off');
+ if (result) {
+ self.displayNotification({
+ type: 'success',
+ message: $data.data('description') ?
+ _.str.sprintf(_t("You've published your %s."), $data.data('description')) :
+ _t("Published with success."),
+ });
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onWebsiteSwitch: function (ev) {
+ var websiteId = ev.currentTarget.getAttribute('website-id');
+ var websiteDomain = ev.currentTarget.getAttribute('domain');
+ let url = `/website/force/${websiteId}`;
+ if (websiteDomain && window.location.hostname !== websiteDomain) {
+ url = websiteDomain + url;
+ }
+ const path = window.location.pathname + window.location.search + window.location.hash;
+ window.location.href = $.param.querystring(url, {'path': path});
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onModalShown: function (ev) {
+ $(ev.target).addClass('modal_shown');
+ },
+ /**
+ * @override
+ */
+ _onKeyDown(ev) {
+ if (!session.user_id) {
+ return;
+ }
+ // If document.body doesn't contain the element, it was probably removed as a consequence of pressing Esc.
+ // we don't want to toggle fullscreen as the removal (eg, closing a modal) is the intended action.
+ if (ev.keyCode !== $.ui.keyCode.ESCAPE || !document.body.contains(ev.target) || ev.target.closest('.modal')) {
+ return KeyboardNavigationMixin._onKeyDown.apply(this, arguments);
+ }
+ this._toggleFullscreen(!this.isFullscreen);
+ },
+});
+
+return {
+ WebsiteRoot: WebsiteRoot,
+ websiteRootRegistry: websiteRootRegistry,
+};
+});
diff --git a/addons/website/static/src/js/content/website_root_instance.js b/addons/website/static/src/js/content/website_root_instance.js
new file mode 100644
index 00000000..fbbefb03
--- /dev/null
+++ b/addons/website/static/src/js/content/website_root_instance.js
@@ -0,0 +1,26 @@
+odoo.define('root.widget', function (require) {
+'use strict';
+
+const AbstractService = require('web.AbstractService');
+const env = require('web.public_env');
+var lazyloader = require('web.public.lazyloader');
+var websiteRootData = require('website.root');
+
+/**
+ * Configure Owl with the public env
+ */
+owl.config.mode = env.isDebug() ? "dev" : "prod";
+owl.Component.env = env;
+
+/**
+ * Deploy services in the env
+ */
+AbstractService.prototype.deployServices(env);
+
+var websiteRoot = new websiteRootData.WebsiteRoot(null);
+return lazyloader.allScriptsLoaded.then(function () {
+ return websiteRoot.attachTo(document.body).then(function () {
+ return websiteRoot;
+ });
+});
+});
diff --git a/addons/website/static/src/js/editor/editor.js b/addons/website/static/src/js/editor/editor.js
new file mode 100644
index 00000000..ca26c092
--- /dev/null
+++ b/addons/website/static/src/js/editor/editor.js
@@ -0,0 +1,18 @@
+odoo.define('website.editor', function (require) {
+'use strict';
+
+var weWidgets = require('web_editor.widget');
+var wUtils = require('website.utils');
+
+weWidgets.LinkDialog.include({
+ /**
+ * Allows the URL input to propose existing website pages.
+ *
+ * @override
+ */
+ start: function () {
+ wUtils.autocompleteWithPages(this, this.$('input[name="url"]'));
+ return this._super.apply(this, arguments);
+ },
+});
+});
diff --git a/addons/website/static/src/js/editor/editor_menu.js b/addons/website/static/src/js/editor/editor_menu.js
new file mode 100644
index 00000000..e330df97
--- /dev/null
+++ b/addons/website/static/src/js/editor/editor_menu.js
@@ -0,0 +1,256 @@
+odoo.define('website.editor.menu', function (require) {
+'use strict';
+
+var Dialog = require('web.Dialog');
+var dom = require('web.dom');
+var Widget = require('web.Widget');
+var core = require('web.core');
+var Wysiwyg = require('web_editor.wysiwyg.root');
+
+var _t = core._t;
+
+var WysiwygMultizone = Wysiwyg.extend({
+ assetLibs: Wysiwyg.prototype.assetLibs.concat(['website.compiled_assets_wysiwyg']),
+ _getWysiwygContructor: function () {
+ return odoo.__DEBUG__.services['web_editor.wysiwyg.multizone'];
+ }
+});
+
+var EditorMenu = Widget.extend({
+ template: 'website.editorbar',
+ xmlDependencies: ['/website/static/src/xml/website.editor.xml'],
+ events: {
+ 'click button[data-action=undo]': '_onUndoClick',
+ 'click button[data-action=redo]': '_onRedoClick',
+ 'click button[data-action=save]': '_onSaveClick',
+ 'click button[data-action=cancel]': '_onCancelClick',
+ },
+ custom_events: {
+ request_save: '_onSnippetRequestSave',
+ get_clean_html: '_onGetCleanHTML',
+ },
+
+ /**
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ this.$el = null; // temporary null to avoid hidden error (@see start)
+ return this._super()
+ .then(function () {
+ var $wrapwrap = $('#wrapwrap');
+ $wrapwrap.removeClass('o_editable'); // clean the dom before edition
+ self.editable($wrapwrap).addClass('o_editable');
+ self.wysiwyg = self._wysiwygInstance();
+ });
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.$el.css({width: '100%'});
+ return this.wysiwyg.attachTo($('#wrapwrap')).then(function () {
+ self.trigger_up('edit_mode');
+ self.$el.css({width: ''});
+ });
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this.trigger_up('readonly_mode');
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Asks the user if they really wants to discard their changes (if any),
+ * then simply reloads the page if they want to.
+ *
+ * @param {boolean} [reload=true]
+ * true if the page has to be reloaded when the user answers yes
+ * (do nothing otherwise but add this to allow class extension)
+ * @returns {Deferred}
+ */
+ cancel: function (reload) {
+ var self = this;
+ var def = new Promise(function (resolve, reject) {
+ if (!self.wysiwyg.isDirty()) {
+ resolve();
+ } else {
+ var confirm = Dialog.confirm(self, _t("If you discard the current edits, all unsaved changes will be lost. You can cancel to return to edit mode."), {
+ confirm_callback: resolve,
+ });
+ confirm.on('closed', self, reject);
+ }
+ });
+
+ return def.then(function () {
+ self.trigger_up('edition_will_stopped');
+ var $wrapwrap = $('#wrapwrap');
+ self.editable($wrapwrap).removeClass('o_editable');
+ if (reload !== false) {
+ window.onbeforeunload = null;
+ self.wysiwyg.destroy();
+ return self._reload();
+ } else {
+ self.wysiwyg.destroy();
+ self.trigger_up('readonly_mode');
+ self.trigger_up('edition_was_stopped');
+ self.destroy();
+ }
+ });
+ },
+ /**
+ * Asks the snippets to clean themself, then saves the page, then reloads it
+ * if asked to.
+ *
+ * @param {boolean} [reload=true]
+ * true if the page has to be reloaded after the save
+ * @returns {Promise}
+ */
+ save: async function (reload) {
+ if (this._saving) {
+ return false;
+ }
+ var self = this;
+ this._saving = true;
+ this.trigger_up('edition_will_stopped');
+ return this.wysiwyg.save(false).then(function (result) {
+ var $wrapwrap = $('#wrapwrap');
+ self.editable($wrapwrap).removeClass('o_editable');
+ if (result.isDirty && reload !== false) {
+ // remove top padding because the connected bar is not visible
+ $('body').removeClass('o_connected_user');
+ return self._reload();
+ } else {
+ self.wysiwyg.destroy();
+ self.trigger_up('edition_was_stopped');
+ self.destroy();
+ }
+ return true;
+ }).guardedCatch(() => {
+ this._saving = false;
+ });
+ },
+ /**
+ * Returns the editable areas on the page.
+ *
+ * @param {DOM} $wrapwrap
+ * @returns {jQuery}
+ */
+ editable: function ($wrapwrap) {
+ return $wrapwrap.find('[data-oe-model]')
+ .not('.o_not_editable')
+ .filter(function () {
+ var $parent = $(this).closest('.o_editable, .o_not_editable');
+ return !$parent.length || $parent.hasClass('o_editable');
+ })
+ .not('link, script')
+ .not('[data-oe-readonly]')
+ .not('img[data-oe-field="arch"], br[data-oe-field="arch"], input[data-oe-field="arch"]')
+ .not('.oe_snippet_editor')
+ .not('hr, br, input, textarea')
+ .add('.o_editable');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _wysiwygInstance: function () {
+ var context;
+ this.trigger_up('context_get', {
+ callback: function (ctx) {
+ context = ctx;
+ },
+ });
+ return new WysiwygMultizone(this, {
+ snippets: 'website.snippets',
+ recordInfo: {
+ context: context,
+ data_res_model: 'website',
+ data_res_id: context.website_id,
+ }
+ });
+ },
+ /**
+ * Reloads the page in non-editable mode, with the right scrolling.
+ *
+ * @private
+ * @returns {Deferred} (never resolved, the page is reloading anyway)
+ */
+ _reload: function () {
+ $('body').addClass('o_wait_reload');
+ this.wysiwyg.destroy();
+ this.$el.hide();
+ window.location.hash = 'scrollTop=' + window.document.body.scrollTop;
+ window.location.reload(true);
+ return new Promise(function () {});
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the "Discard" button is clicked -> discards the changes.
+ *
+ * @private
+ */
+ _onCancelClick: function () {
+ this.cancel(true);
+ },
+ /**
+ * Get the cleaned value of the editable element.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onGetCleanHTML: function (ev) {
+ ev.data.callback(this.wysiwyg.getValue({$layout: ev.data.$layout}));
+ },
+ /**
+ * Snippet (menu_data) can request to save the document to leave the page
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {object} ev.data
+ * @param {function} ev.data.onSuccess
+ * @param {function} ev.data.onFailure
+ */
+ _onSnippetRequestSave: function (ev) {
+ this.save(false).then(ev.data.onSuccess, ev.data.onFailure);
+ },
+ /**
+ * Called when the "Save" button is clicked -> saves the changes.
+ *
+ * @private
+ */
+ _onSaveClick: function (ev) {
+ const restore = dom.addButtonLoadingEffect(ev.currentTarget);
+ this.save().then(restore).guardedCatch(restore);
+ },
+ /**
+ * @private
+ */
+ _onUndoClick() {
+ $('.note-history [data-event=undo]').first().click();
+ },
+ /**
+ * @private
+ */
+ _onRedoClick() {
+ $('.note-history [data-event=redo]').first().click();
+ },
+});
+
+return EditorMenu;
+});
diff --git a/addons/website/static/src/js/editor/editor_menu_translate.js b/addons/website/static/src/js/editor/editor_menu_translate.js
new file mode 100644
index 00000000..3016ade6
--- /dev/null
+++ b/addons/website/static/src/js/editor/editor_menu_translate.js
@@ -0,0 +1,109 @@
+odoo.define('website.editor.menu.translate', function (require) {
+'use strict';
+
+require('web.dom_ready');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var localStorage = require('web.local_storage');
+var Wysiwyg = require('web_editor.wysiwyg.root');
+var EditorMenu = require('website.editor.menu');
+
+var _t = core._t;
+
+var localStorageNoDialogKey = 'website_translator_nodialog';
+
+var TranslatorInfoDialog = Dialog.extend({
+ template: 'website.TranslatorInfoDialog',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/website/static/src/xml/translator.xml']
+ ),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options) {
+ this._super(parent, _.extend({
+ title: _t("Translation Info"),
+ buttons: [
+ {text: _t("Ok, never show me this again"), classes: 'btn-primary', close: true, click: this._onStrongOk.bind(this)},
+ {text: _t("Ok"), close: true}
+ ],
+ }, options || {}));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the "strong" ok is clicked -> adapt localstorage to make sure
+ * the dialog is never displayed again.
+ *
+ * @private
+ */
+ _onStrongOk: function () {
+ localStorage.setItem(localStorageNoDialogKey, true);
+ },
+});
+
+var WysiwygTranslate = Wysiwyg.extend({
+ assetLibs: Wysiwyg.prototype.assetLibs.concat(['website.compiled_assets_wysiwyg']),
+ _getWysiwygContructor: function () {
+ return odoo.__DEBUG__.services['web_editor.wysiwyg.multizone.translate'];
+ }
+});
+
+var TranslatorMenu = EditorMenu.extend({
+
+ /**
+ * @override
+ */
+ start: function () {
+ if (!localStorage.getItem(localStorageNoDialogKey)) {
+ new TranslatorInfoDialog(this).open();
+ }
+
+ return this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns the editable areas on the page.
+ *
+ * @param {DOM} $wrapwrap
+ * @returns {jQuery}
+ */
+ editable: function ($wrapwrap) {
+ var selector = '[data-oe-translation-id], '+
+ '[data-oe-model][data-oe-id][data-oe-field], ' +
+ '[placeholder*="data-oe-translation-id="], ' +
+ '[title*="data-oe-translation-id="], ' +
+ '[alt*="data-oe-translation-id="]';
+ var $edit = $wrapwrap.find(selector);
+ $edit.filter(':has(' + selector + ')').attr('data-oe-readonly', true);
+ return $edit.not('[data-oe-readonly]');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _wysiwygInstance: function () {
+ var context;
+ this.trigger_up('context_get', {
+ callback: function (ctx) {
+ context = ctx;
+ },
+ });
+ return new WysiwygTranslate(this, {lang: context.lang});
+ },
+});
+
+return TranslatorMenu;
+});
diff --git a/addons/website/static/src/js/editor/mega_menu.js b/addons/website/static/src/js/editor/mega_menu.js
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/addons/website/static/src/js/editor/mega_menu.js
diff --git a/addons/website/static/src/js/editor/rte.summernote.js b/addons/website/static/src/js/editor/rte.summernote.js
new file mode 100644
index 00000000..36cadca7
--- /dev/null
+++ b/addons/website/static/src/js/editor/rte.summernote.js
@@ -0,0 +1,59 @@
+odoo.define('website.rte.summernote', function (require) {
+'use strict';
+
+var core = require('web.core');
+require('web_editor.rte.summernote');
+
+var eventHandler = $.summernote.eventHandler;
+var renderer = $.summernote.renderer;
+var tplIconButton = renderer.getTemplate().iconButton;
+var _t = core._t;
+
+var fn_tplPopovers = renderer.tplPopovers;
+renderer.tplPopovers = function (lang, options) {
+ var $popover = $(fn_tplPopovers.call(this, lang, options));
+ $popover.find('.note-image-popover .btn-group:has([data-value="img-thumbnail"])').append(
+ tplIconButton('fa fa-object-ungroup', {
+ title: _t('Transform the picture (click twice to reset transformation)'),
+ event: 'transform',
+ }));
+ return $popover;
+};
+
+$.summernote.pluginEvents.transform = function (event, editor, layoutInfo, sorted) {
+ var $selection = layoutInfo.handle().find('.note-control-selection');
+ var $image = $($selection.data('target'));
+
+ if ($image.data('transfo-destroy')) {
+ $image.removeData('transfo-destroy');
+ return;
+ }
+
+ $image.transfo();
+
+ var mouseup = function (event) {
+ $('.note-popover button[data-event="transform"]').toggleClass('active', $image.is('[style*="transform"]'));
+ };
+ $(document).on('mouseup', mouseup);
+
+ var mousedown = function (event) {
+ if (!$(event.target).closest('.transfo-container').length) {
+ $image.transfo('destroy');
+ $(document).off('mousedown', mousedown).off('mouseup', mouseup);
+ }
+ if ($(event.target).closest('.note-popover').length) {
+ $image.data('transfo-destroy', true).attr('style', ($image.attr('style') || '').replace(/[^;]*transform[\w:]*;?/g, ''));
+ }
+ $image.trigger('content_changed');
+ };
+ $(document).on('mousedown', mousedown);
+};
+
+var fn_boutton_update = eventHandler.modules.popover.button.update;
+eventHandler.modules.popover.button.update = function ($container, oStyle) {
+ fn_boutton_update.call(this, $container, oStyle);
+ $container.find('button[data-event="transform"]')
+ .toggleClass('active', $(oStyle.image).is('[style*="transform"]'))
+ .toggleClass('d-none', !$(oStyle.image).is('img'));
+};
+});
diff --git a/addons/website/static/src/js/editor/snippets.editor.js b/addons/website/static/src/js/editor/snippets.editor.js
new file mode 100644
index 00000000..15f80046
--- /dev/null
+++ b/addons/website/static/src/js/editor/snippets.editor.js
@@ -0,0 +1,245 @@
+odoo.define('website.snippet.editor', function (require) {
+'use strict';
+
+const {qweb, _t, _lt} = require('web.core');
+const Dialog = require('web.Dialog');
+const weSnippetEditor = require('web_editor.snippet.editor');
+const wSnippetOptions = require('website.editor.snippets.options');
+
+const FontFamilyPickerUserValueWidget = wSnippetOptions.FontFamilyPickerUserValueWidget;
+
+weSnippetEditor.Class.include({
+ xmlDependencies: (weSnippetEditor.Class.prototype.xmlDependencies || [])
+ .concat(['/website/static/src/xml/website.editor.xml']),
+ events: _.extend({}, weSnippetEditor.Class.prototype.events, {
+ 'click .o_we_customize_theme_btn': '_onThemeTabClick',
+ }),
+ custom_events: Object.assign({}, weSnippetEditor.Class.prototype.custom_events, {
+ 'gmap_api_request': '_onGMapAPIRequest',
+ 'gmap_api_key_request': '_onGMapAPIKeyRequest',
+ }),
+ tabs: _.extend({}, weSnippetEditor.Class.prototype.tabs, {
+ THEME: 'theme',
+ }),
+ optionsTabStructure: [
+ ['theme-colors', _lt("Theme Colors")],
+ ['theme-options', _lt("Theme Options")],
+ ['website-settings', _lt("Website Settings")],
+ ],
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeSnippetTemplates: function (html) {
+ const $html = $(html);
+ const fontVariables = _.map($html.find('we-fontfamilypicker[data-variable]'), el => {
+ return el.dataset.variable;
+ });
+ FontFamilyPickerUserValueWidget.prototype.fontVariables = fontVariables;
+
+ return this._super(...arguments);
+ },
+ /**
+ * Depending of the demand, reconfigure they gmap key or configure it
+ * if not already defined.
+ *
+ * @private
+ * @param {boolean} [reconfigure=false]
+ * @param {boolean} [onlyIfUndefined=false]
+ */
+ async _configureGMapAPI({reconfigure, onlyIfUndefined}) {
+ const apiKey = await new Promise(resolve => {
+ this.getParent().trigger_up('gmap_api_key_request', {
+ onSuccess: key => resolve(key),
+ });
+ });
+ if (!reconfigure && (apiKey || !onlyIfUndefined)) {
+ return false;
+ }
+ let websiteId;
+ this.trigger_up('context_get', {
+ callback: ctx => websiteId = ctx['website_id'],
+ });
+ return new Promise(resolve => {
+ let invalidated = false;
+ const dialog = new Dialog(this, {
+ size: 'medium',
+ title: _t("Google Map API Key"),
+ buttons: [
+ {text: _t("Save"), classes: 'btn-primary', click: async (ev) => {
+ const $apiKeyInput = dialog.$('#api_key_input');
+ const valueAPIKey = $apiKeyInput.val();
+ const $apiKeyHelp = dialog.$('#api_key_help');
+ if (!valueAPIKey) {
+ $apiKeyInput.addClass('is-invalid');
+ $apiKeyHelp.text(_t("Enter an API Key"));
+ return;
+ }
+ const $button = $(ev.currentTarget);
+ $button.prop('disabled', true);
+ try {
+ const response = await fetch(`https://maps.googleapis.com/maps/api/staticmap?center=belgium&size=10x10&key=${valueAPIKey}`);
+ if (response.status === 200) {
+ await this._rpc({
+ model: 'website',
+ method: 'write',
+ args: [
+ [websiteId],
+ {google_maps_api_key: valueAPIKey},
+ ],
+ });
+ invalidated = true;
+ dialog.close();
+ } else {
+ const text = await response.text();
+ $apiKeyInput.addClass('is-invalid');
+ $apiKeyHelp.empty().text(
+ _t("Invalid API Key. The following error was returned by Google:")
+ ).append($('<i/>', {
+ text: text,
+ class: 'ml-1',
+ }));
+ }
+ } catch (e) {
+ $apiKeyHelp.text(_t("Check your connection and try again"));
+ } finally {
+ $button.prop("disabled", false);
+ }
+ }},
+ {text: _t("Cancel"), close: true}
+ ],
+ $content: $(qweb.render('website.s_google_map_modal', {
+ apiKey: apiKey,
+ })),
+ });
+ dialog.on('closed', this, () => resolve(invalidated));
+ dialog.open();
+ });
+ },
+ /**
+ * @override
+ */
+ _getScrollOptions(options = {}) {
+ const finalOptions = this._super(...arguments);
+ if (!options.offsetElements || !options.offsetElements.$top) {
+ const $header = $('#top');
+ if ($header.length) {
+ finalOptions.offsetElements = finalOptions.offsetElements || {};
+ finalOptions.offsetElements.$top = $header;
+ }
+ }
+ return finalOptions;
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {string} gmapRequestEventName
+ */
+ async _handleGMapRequest(ev, gmapRequestEventName) {
+ ev.stopPropagation();
+ const reconfigured = await this._configureGMapAPI({
+ reconfigure: ev.data.reconfigure,
+ onlyIfUndefined: ev.data.configureIfNecessary,
+ });
+ this.getParent().trigger_up(gmapRequestEventName, {
+ refetch: reconfigured,
+ editableMode: true,
+ onSuccess: key => ev.data.onSuccess(key),
+ });
+ },
+ /**
+ * @override
+ */
+ _updateLeftPanelContent: function ({content, tab}) {
+ this._super(...arguments);
+ this.$('.o_we_customize_theme_btn').toggleClass('active', tab === this.tabs.THEME);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onGMapAPIRequest(ev) {
+ this._handleGMapRequest(ev, 'gmap_api_request');
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onGMapAPIKeyRequest(ev) {
+ this._handleGMapRequest(ev, 'gmap_api_key_request');
+ },
+ /**
+ * @private
+ */
+ async _onThemeTabClick(ev) {
+ // Note: nothing async here but start the loading effect asap
+ let releaseLoader;
+ try {
+ const promise = new Promise(resolve => releaseLoader = resolve);
+ this._execWithLoadingEffect(() => promise, false, 0);
+ // loader is added to the DOM synchronously
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ // ensure loader is rendered: first call asks for the (already done) DOM update,
+ // second call happens only after rendering the first "updates"
+
+ if (!this.topFakeOptionEl) {
+ let el;
+ for (const [elementName, title] of this.optionsTabStructure) {
+ const newEl = document.createElement(elementName);
+ newEl.dataset.name = title;
+ if (el) {
+ el.appendChild(newEl);
+ } else {
+ this.topFakeOptionEl = newEl;
+ }
+ el = newEl;
+ }
+ this.bottomFakeOptionEl = el;
+ this.el.appendChild(this.topFakeOptionEl);
+ }
+
+ // Need all of this in that order so that:
+ // - the element is visible and can be enabled and the onFocus method is
+ // called each time.
+ // - the element is hidden afterwards so it does not take space in the
+ // DOM, same as the overlay which may make a scrollbar appear.
+ this.topFakeOptionEl.classList.remove('d-none');
+ const editorPromise = this._activateSnippet($(this.bottomFakeOptionEl));
+ releaseLoader(); // because _activateSnippet uses the same mutex as the loader
+ releaseLoader = undefined;
+ const editor = await editorPromise;
+ this.topFakeOptionEl.classList.add('d-none');
+ editor.toggleOverlay(false);
+
+ this._updateLeftPanelContent({
+ tab: this.tabs.THEME,
+ });
+ } catch (e) {
+ // Normally the loading effect is removed in case of error during the action but here
+ // the actual activity is happening outside of the action, the effect must therefore
+ // be cleared in case of error as well
+ if (releaseLoader) {
+ releaseLoader();
+ }
+ throw e;
+ }
+ },
+});
+
+weSnippetEditor.Editor.include({
+ layoutElementsSelector: [
+ weSnippetEditor.Editor.prototype.layoutElementsSelector,
+ '.s_parallax_bg',
+ '.o_bg_video_container',
+ ].join(','),
+});
+});
diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js
new file mode 100644
index 00000000..d0b032a1
--- /dev/null
+++ b/addons/website/static/src/js/editor/snippets.options.js
@@ -0,0 +1,2612 @@
+odoo.define('website.editor.snippets.options', function (require) {
+'use strict';
+
+const {ColorpickerWidget} = require('web.Colorpicker');
+const config = require('web.config');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+const dom = require('web.dom');
+const weUtils = require('web_editor.utils');
+var options = require('web_editor.snippets.options');
+const wUtils = require('website.utils');
+require('website.s_popup_options');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+const InputUserValueWidget = options.userValueWidgetsRegistry['we-input'];
+const SelectUserValueWidget = options.userValueWidgetsRegistry['we-select'];
+
+const UrlPickerUserValueWidget = InputUserValueWidget.extend({
+ custom_events: _.extend({}, InputUserValueWidget.prototype.custom_events || {}, {
+ 'website_url_chosen': '_onWebsiteURLChosen',
+ }),
+ events: _.extend({}, InputUserValueWidget.prototype.events || {}, {
+ 'click .o_we_redirect_to': '_onRedirectTo',
+ }),
+
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+ const linkButton = document.createElement('we-button');
+ const icon = document.createElement('i');
+ icon.classList.add('fa', 'fa-fw', 'fa-external-link')
+ linkButton.classList.add('o_we_redirect_to');
+ linkButton.title = _t("Redirect to URL in a new tab");
+ linkButton.appendChild(icon);
+ this.containerEl.appendChild(linkButton);
+ this.el.classList.add('o_we_large_input');
+ this.inputEl.classList.add('text-left');
+ const options = {
+ position: {
+ collision: 'flip fit',
+ },
+ classes: {
+ "ui-autocomplete": 'o_website_ui_autocomplete'
+ },
+ }
+ wUtils.autocompleteWithPages(this, $(this.inputEl), options);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the autocomplete change the input value.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onWebsiteURLChosen: function (ev) {
+ this._value = this.inputEl.value;
+ this._onUserValueChange(ev);
+ },
+ /**
+ * Redirects to the URL the widget currently holds.
+ *
+ * @private
+ */
+ _onRedirectTo: function () {
+ if (this._value) {
+ window.open(this._value, '_blank');
+ }
+ },
+});
+
+const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({
+ xmlDependencies: (SelectUserValueWidget.prototype.xmlDependencies || [])
+ .concat(['/website/static/src/xml/website.editor.xml']),
+ events: _.extend({}, SelectUserValueWidget.prototype.events || {}, {
+ 'click .o_we_add_google_font_btn': '_onAddGoogleFontClick',
+ 'click .o_we_delete_google_font_btn': '_onDeleteGoogleFontClick',
+ }),
+ fontVariables: [], // Filled by editor menu when all options are loaded
+
+ /**
+ * @override
+ */
+ start: async function () {
+ const style = window.getComputedStyle(document.documentElement);
+ const nbFonts = parseInt(weUtils.getCSSVariableValue('number-of-fonts', style));
+ const googleFontsProperty = weUtils.getCSSVariableValue('google-fonts', style);
+ this.googleFonts = googleFontsProperty ? googleFontsProperty.split(/\s*,\s*/g) : [];
+ this.googleFonts = this.googleFonts.map(font => font.substring(1, font.length - 1)); // Unquote
+
+ await this._super(...arguments);
+
+ const fontEls = [];
+ const methodName = this.el.dataset.methodName || 'customizeWebsiteVariable';
+ const variable = this.el.dataset.variable;
+ _.times(nbFonts, fontNb => {
+ const realFontNb = fontNb + 1;
+ const fontEl = document.createElement('we-button');
+ fontEl.classList.add(`o_we_option_font_${realFontNb}`);
+ fontEl.dataset.variable = variable;
+ fontEl.dataset[methodName] = weUtils.getCSSVariableValue(`font-number-${realFontNb}`, style);
+ fontEl.dataset.font = realFontNb;
+ fontEls.push(fontEl);
+ this.menuEl.appendChild(fontEl);
+ });
+
+ if (this.googleFonts.length) {
+ const googleFontsEls = fontEls.slice(-this.googleFonts.length);
+ googleFontsEls.forEach((el, index) => {
+ $(el).append(core.qweb.render('website.delete_google_font_btn', {
+ index: index,
+ }));
+ });
+ }
+ $(this.menuEl).append($(core.qweb.render('website.add_google_font_btn', {
+ variable: variable,
+ })));
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+
+ for (const className of this.menuTogglerEl.classList) {
+ if (className.match(/^o_we_option_font_\d+$/)) {
+ this.menuTogglerEl.classList.remove(className);
+ }
+ }
+ const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive());
+ if (activeWidget) {
+ this.menuTogglerEl.classList.add(`o_we_option_font_${activeWidget.el.dataset.font}`);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAddGoogleFontClick: function (ev) {
+ const variable = $(ev.currentTarget).data('variable');
+ const dialog = new Dialog(this, {
+ title: _t("Add a Google Font"),
+ $content: $(core.qweb.render('website.dialog.addGoogleFont')),
+ buttons: [
+ {
+ text: _t("Save & Reload"),
+ classes: 'btn-primary',
+ click: async () => {
+ const inputEl = dialog.el.querySelector('.o_input_google_font');
+ // if font page link (what is expected)
+ let m = inputEl.value.match(/\bspecimen\/([\w+]+)/);
+ if (!m) {
+ // if embed code (so that it works anyway if the user put the embed code instead of the page link)
+ m = inputEl.value.match(/\bfamily=([\w+]+)/);
+ if (!m) {
+ inputEl.classList.add('is-invalid');
+ return;
+ }
+ }
+
+ let isValidFamily = false;
+
+ try {
+ const result = await fetch("https://fonts.googleapis.com/css?family=" + m[1], {method: 'HEAD'});
+ // Google fonts server returns a 400 status code if family is not valid.
+ if (result.ok) {
+ isValidFamily = true;
+ }
+ } catch (error) {
+ console.error(error);
+ }
+
+ if (!isValidFamily) {
+ inputEl.classList.add('is-invalid');
+ return;
+ }
+
+ const font = m[1].replace(/\+/g, ' ');
+ this.googleFonts.push(font);
+ this.trigger_up('google_fonts_custo_request', {
+ values: {[variable]: `'${font}'`},
+ googleFonts: this.googleFonts,
+ });
+ },
+ },
+ {
+ text: _t("Discard"),
+ close: true,
+ },
+ ],
+ });
+ dialog.open();
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onDeleteGoogleFontClick: async function (ev) {
+ ev.preventDefault();
+
+ const save = await new Promise(resolve => {
+ Dialog.confirm(this, _t("Deleting a font requires a reload of the page. This will save all your changes and reload the page, are you sure you want to proceed?"), {
+ confirm_callback: () => resolve(true),
+ cancel_callback: () => resolve(false),
+ });
+ });
+ if (!save) {
+ return;
+ }
+
+ // Remove Google font
+ const googleFontIndex = parseInt(ev.target.dataset.fontIndex);
+ const googleFont = this.googleFonts[googleFontIndex];
+ this.googleFonts.splice(googleFontIndex, 1);
+
+ // Adapt font variable indexes to the removal
+ const values = {};
+ const style = window.getComputedStyle(document.documentElement);
+ _.each(FontFamilyPickerUserValueWidget.prototype.fontVariables, variable => {
+ const value = weUtils.getCSSVariableValue(variable, style);
+ if (value.substring(1, value.length - 1) === googleFont) {
+ // If an element is using the google font being removed, reset
+ // it to the theme default.
+ values[variable] = 'null';
+ }
+ });
+
+ this.trigger_up('google_fonts_custo_request', {
+ values: values,
+ googleFonts: this.googleFonts,
+ });
+ },
+});
+
+const GPSPicker = InputUserValueWidget.extend({
+ events: { // Explicitely not consider all InputUserValueWidget events
+ 'blur input': '_onInputBlur',
+ },
+
+ /**
+ * @constructor
+ */
+ init() {
+ this._super(...arguments);
+ this._gmapCacheGPSToPlace = {};
+ },
+ /**
+ * @override
+ */
+ async willStart() {
+ await this._super(...arguments);
+ this._gmapLoaded = await new Promise(resolve => {
+ this.trigger_up('gmap_api_request', {
+ editableMode: true,
+ configureIfNecessary: true,
+ onSuccess: key => resolve(!!key),
+ });
+ });
+ if (!this._gmapLoaded) {
+ this.trigger_up('user_value_widget_critical');
+ return;
+ }
+ },
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+ this.el.classList.add('o_we_large_input');
+ if (!this._gmapLoaded) {
+ return;
+ }
+
+ this._gmapAutocomplete = new google.maps.places.Autocomplete(this.inputEl, {types: ['geocode']});
+ google.maps.event.addListener(this._gmapAutocomplete, 'place_changed', this._onPlaceChanged.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getMethodsParams: function (methodName) {
+ return Object.assign({gmapPlace: this._gmapPlace || {}}, this._super(...arguments));
+ },
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+
+ await new Promise(resolve => {
+ const gps = this._value;
+ if (this._gmapCacheGPSToPlace[gps]) {
+ this._gmapPlace = this._gmapCacheGPSToPlace[gps];
+ resolve();
+ return;
+ }
+ const service = new google.maps.places.PlacesService(document.createElement('div'));
+ const p = gps.substring(1).slice(0, -1).split(',');
+ const location = new google.maps.LatLng(p[0] || 0, p[1] || 0);
+ service.nearbySearch({
+ // Do a 'nearbySearch' followed by 'getDetails' to avoid using
+ // GMap Geocoder which the user may not have enabled... but
+ // ideally Geocoder should be used to get the exact location at
+ // those coordinates and to limit billing query count.
+ location: location,
+ radius: 1,
+ }, (results, status) => {
+ const GMAP_CRITICAL_ERRORS = [google.maps.places.PlacesServiceStatus.REQUEST_DENIED, google.maps.places.PlacesServiceStatus.UNKNOWN_ERROR];
+ if (status === google.maps.places.PlacesServiceStatus.OK) {
+ service.getDetails({
+ placeId: results[0].place_id,
+ fields: ['geometry', 'formatted_address'],
+ }, (place, status) => {
+ resolve();
+ if (status === google.maps.places.PlacesServiceStatus.OK) {
+ this._gmapCacheGPSToPlace[gps] = place;
+ this._gmapPlace = place;
+ } else if (GMAP_CRITICAL_ERRORS.includes(status)) {
+ this.trigger_up('user_value_widget_critical');
+ }
+ });
+ } else if (GMAP_CRITICAL_ERRORS.includes(status)) {
+ resolve();
+ this.trigger_up('user_value_widget_critical');
+ }
+ });
+ });
+ if (this._gmapPlace) {
+ this.inputEl.value = this._gmapPlace.formatted_address;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onPlaceChanged(ev) {
+ const gmapPlace = this._gmapAutocomplete.getPlace();
+ if (gmapPlace && gmapPlace.geometry) {
+ this._gmapPlace = gmapPlace;
+ const location = this._gmapPlace.geometry.location;
+ this._value = `(${location.lat()},${location.lng()})`;
+ this._gmapCacheGPSToPlace[this._value] = gmapPlace;
+ this._onUserValueChange(ev);
+ }
+ },
+});
+
+options.userValueWidgetsRegistry['we-urlpicker'] = UrlPickerUserValueWidget;
+options.userValueWidgetsRegistry['we-fontfamilypicker'] = FontFamilyPickerUserValueWidget;
+options.userValueWidgetsRegistry['we-gpspicker'] = GPSPicker;
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+options.Class.include({
+ xmlDependencies: (options.Class.prototype.xmlDependencies || [])
+ .concat(['/website/static/src/xml/website.editor.xml']),
+ custom_events: _.extend({}, options.Class.prototype.custom_events || {}, {
+ 'google_fonts_custo_request': '_onGoogleFontsCustoRequest',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for parameters
+ */
+ customizeWebsiteViews: async function (previewMode, widgetValue, params) {
+ await this._customizeWebsite(previewMode, widgetValue, params, 'views');
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ customizeWebsiteVariable: async function (previewMode, widgetValue, params) {
+ await this._customizeWebsite(previewMode, widgetValue, params, 'variable');
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ customizeWebsiteColor: async function (previewMode, widgetValue, params) {
+ await this._customizeWebsite(previewMode, widgetValue, params, 'color');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async _checkIfWidgetsUpdateNeedReload(widgets) {
+ const needReload = await this._super(...arguments);
+ if (needReload) {
+ return needReload;
+ }
+ for (const widget of widgets) {
+ const methodsNames = widget.getMethodsNames();
+ if (!methodsNames.includes('customizeWebsiteViews')
+ && !methodsNames.includes('customizeWebsiteVariable')
+ && !methodsNames.includes('customizeWebsiteColor')) {
+ continue;
+ }
+ let paramsReload = false;
+ if (widget.getMethodsParams('customizeWebsiteViews').reload
+ || widget.getMethodsParams('customizeWebsiteVariable').reload
+ || widget.getMethodsParams('customizeWebsiteColor').reload) {
+ paramsReload = true;
+ }
+ if (paramsReload || config.isDebug('assets')) {
+ return (config.isDebug('assets') ? _t("It appears you are in debug=assets mode, all theme customization options require a page reload in this mode.") : true);
+ }
+ }
+ return false;
+ },
+ /**
+ * @override
+ */
+ _computeWidgetState: async function (methodName, params) {
+ switch (methodName) {
+ case 'customizeWebsiteViews': {
+ const allXmlIDs = this._getXMLIDsFromPossibleValues(params.possibleValues);
+ const enabledXmlIDs = await this._rpc({
+ route: '/website/theme_customize_get',
+ params: {
+ 'xml_ids': allXmlIDs,
+ },
+ });
+ let mostXmlIDsStr = '';
+ let mostXmlIDsNb = 0;
+ for (const xmlIDsStr of params.possibleValues) {
+ const enableXmlIDs = xmlIDsStr.split(/\s*,\s*/);
+ if (enableXmlIDs.length > mostXmlIDsNb
+ && enableXmlIDs.every(xmlID => enabledXmlIDs.includes(xmlID))) {
+ mostXmlIDsStr = xmlIDsStr;
+ mostXmlIDsNb = enableXmlIDs.length;
+ }
+ }
+ return mostXmlIDsStr; // Need to return the exact same string as in possibleValues
+ }
+ case 'customizeWebsiteVariable': {
+ return weUtils.getCSSVariableValue(params.variable);
+ }
+ case 'customizeWebsiteColor': {
+ // TODO adapt in master
+ const bugfixedValue = weUtils.getCSSVariableValue(`bugfixed-${params.color}`);
+ if (bugfixedValue) {
+ return bugfixedValue;
+ }
+ return weUtils.getCSSVariableValue(params.color);
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ */
+ _customizeWebsite: async function (previewMode, widgetValue, params, type) {
+ // Never allow previews for theme customizations
+ if (previewMode) {
+ return;
+ }
+
+ switch (type) {
+ case 'views':
+ await this._customizeWebsiteViews(widgetValue, params);
+ break;
+ case 'variable':
+ await this._customizeWebsiteVariable(widgetValue, params);
+ break;
+ case 'color':
+ await this._customizeWebsiteColor(widgetValue, params);
+ break;
+ }
+
+ if (params.reload || config.isDebug('assets')) {
+ // Caller will reload the page, nothing needs to be done anymore.
+ return;
+ }
+
+ // Finally, only update the bundles as no reload is required
+ await this._reloadBundles();
+
+ // Some public widgets may depend on the variables that were
+ // customized, so we have to restart them *all*.
+ await new Promise((resolve, reject) => {
+ this.trigger_up('widgets_start_request', {
+ editableMode: true,
+ onSuccess: () => resolve(),
+ onFailure: () => reject(),
+ });
+ });
+ },
+ /**
+ * @private
+ */
+ _customizeWebsiteColor: async function (color, params) {
+ const baseURL = '/website/static/src/scss/options/colors/';
+ const colorType = params.colorType ? (params.colorType + '_') : '';
+ const url = `${baseURL}user_${colorType}color_palette.scss`;
+
+ if (color) {
+ if (weUtils.isColorCombinationName(color)) {
+ color = parseInt(color);
+ } else if (!ColorpickerWidget.isCSSColor(color)) {
+ color = `'${color}'`;
+ }
+ }
+ return this._makeSCSSCusto(url, {[params.color]: color});
+ },
+ /**
+ * @private
+ */
+ _customizeWebsiteVariable: async function (value, params) {
+ return this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', {
+ [params.variable]: value,
+ });
+ },
+ /**
+ * @private
+ */
+ _customizeWebsiteViews: async function (xmlID, params) {
+ const allXmlIDs = this._getXMLIDsFromPossibleValues(params.possibleValues);
+ const enableXmlIDs = xmlID.split(/\s*,\s*/);
+ const disableXmlIDs = allXmlIDs.filter(xmlID => !enableXmlIDs.includes(xmlID));
+
+ return this._rpc({
+ route: '/website/theme_customize',
+ params: {
+ 'enable': enableXmlIDs,
+ 'disable': disableXmlIDs,
+ },
+ });
+ },
+ /**
+ * @private
+ */
+ _getXMLIDsFromPossibleValues: function (possibleValues) {
+ const allXmlIDs = [];
+ for (const xmlIDsStr of possibleValues) {
+ allXmlIDs.push(...xmlIDsStr.split(/\s*,\s*/));
+ }
+ return allXmlIDs.filter((v, i, arr) => arr.indexOf(v) === i);
+ },
+ /**
+ * @private
+ */
+ _makeSCSSCusto: async function (url, values) {
+ return this._rpc({
+ route: '/website/make_scss_custo',
+ params: {
+ 'url': url,
+ 'values': _.mapObject(values, v => v || 'null'),
+ },
+ });
+ },
+ /**
+ * Refreshes all public widgets related to the given element.
+ *
+ * @private
+ * @param {jQuery} [$el=this.$target]
+ * @returns {Promise}
+ */
+ _refreshPublicWidgets: async function ($el) {
+ return new Promise((resolve, reject) => {
+ this.trigger_up('widgets_start_request', {
+ editableMode: true,
+ $target: $el || this.$target,
+ onSuccess: resolve,
+ onFailure: reject,
+ });
+ });
+ },
+ /**
+ * @private
+ */
+ _reloadBundles: async function () {
+ const bundles = await this._rpc({
+ route: '/website/theme_customize_bundle_reload',
+ });
+ let $allLinks = $();
+ const proms = _.map(bundles, (bundleURLs, bundleName) => {
+ var $links = $('link[href*="' + bundleName + '"]');
+ $allLinks = $allLinks.add($links);
+ var $newLinks = $();
+ _.each(bundleURLs, url => {
+ $newLinks = $newLinks.add($('<link/>', {
+ type: 'text/css',
+ rel: 'stylesheet',
+ href: url,
+ }));
+ });
+
+ const linksLoaded = new Promise(resolve => {
+ let nbLoaded = 0;
+ $newLinks.on('load error', () => { // If we have an error, just ignore it
+ if (++nbLoaded >= $newLinks.length) {
+ resolve();
+ }
+ });
+ });
+ $links.last().after($newLinks);
+ return linksLoaded;
+ });
+ await Promise.all(proms).then(() => $allLinks.remove());
+ },
+ /**
+ * @override
+ */
+ _select: async function (previewMode, widget) {
+ await this._super(...arguments);
+
+ if (!widget.$el.closest('[data-no-widget-refresh="true"]').length) {
+ // TODO the flag should be retrieved through widget params somehow
+ await this._refreshPublicWidgets();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onGoogleFontsCustoRequest: function (ev) {
+ const values = ev.data.values ? _.clone(ev.data.values) : {};
+ const googleFonts = ev.data.googleFonts;
+ if (googleFonts.length) {
+ values['google-fonts'] = "('" + googleFonts.join("', '") + "')";
+ } else {
+ values['google-fonts'] = 'null';
+ }
+ this.trigger_up('snippet_edition_request', {exec: async () => {
+ return this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', values);
+ }});
+ this.trigger_up('request_save', {
+ reloadEditor: true,
+ });
+ },
+});
+
+function _getLastPreFilterLayerElement($el) {
+ // Make sure parallax and video element are considered to be below the
+ // color filters / shape
+ const $bgVideo = $el.find('> .o_bg_video_container');
+ if ($bgVideo.length) {
+ return $bgVideo[0];
+ }
+ const $parallaxEl = $el.find('> .s_parallax_bg');
+ if ($parallaxEl.length) {
+ return $parallaxEl[0];
+ }
+ return null;
+}
+
+options.registry.BackgroundToggler.include({
+ /**
+ * Toggles background video on or off.
+ *
+ * @see this.selectClass for parameters
+ */
+ toggleBgVideo(previewMode, widgetValue, params) {
+ if (!widgetValue) {
+ // TODO: use setWidgetValue instead of calling background directly when possible
+ const [bgVideoWidget] = this._requestUserValueWidgets('bg_video_opt');
+ const bgVideoOpt = bgVideoWidget.getParent();
+ return bgVideoOpt._setBgVideo(false, '');
+ } else {
+ // TODO: use trigger instead of el.click when possible
+ this._requestUserValueWidgets('bg_video_opt')[0].el.click();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ if (methodName === 'toggleBgVideo') {
+ return this.$target[0].classList.contains('o_background_video');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * TODO an overall better management of background layers is needed
+ *
+ * @override
+ */
+ _getLastPreFilterLayerElement() {
+ const el = _getLastPreFilterLayerElement(this.$target);
+ if (el) {
+ return el;
+ }
+ return this._super(...arguments);
+ },
+});
+
+options.registry.BackgroundShape.include({
+ /**
+ * TODO need a better management of background layers
+ *
+ * @override
+ */
+ _getLastPreShapeLayerElement() {
+ const el = this._super(...arguments);
+ if (el) {
+ return el;
+ }
+ return _getLastPreFilterLayerElement(this.$target);
+ }
+});
+
+options.registry.BackgroundVideo = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the target's background video.
+ *
+ * @see this.selectClass for parameters
+ */
+ background: function (previewMode, widgetValue, params) {
+ if (previewMode === 'reset' && this.videoSrc) {
+ return this._setBgVideo(false, this.videoSrc);
+ }
+ return this._setBgVideo(previewMode, widgetValue);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ if (methodName === 'background') {
+ if (this.$target[0].classList.contains('o_background_video')) {
+ return this.$('> .o_bg_video_container iframe').attr('src');
+ }
+ return '';
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Updates the background video used by the snippet.
+ *
+ * @private
+ * @see this.selectClass for parameters
+ * @returns {Promise}
+ */
+ _setBgVideo: async function (previewMode, value) {
+ this.$('> .o_bg_video_container').toggleClass('d-none', previewMode === true);
+
+ if (previewMode !== false) {
+ return;
+ }
+
+ this.videoSrc = value;
+ var target = this.$target[0];
+ target.classList.toggle('o_background_video', !!(value && value.length));
+ if (value && value.length) {
+ target.dataset.bgVideoSrc = value;
+ } else {
+ delete target.dataset.bgVideoSrc;
+ }
+ await this._refreshPublicWidgets();
+ },
+});
+
+options.registry.OptionsTab = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for parameters
+ */
+ async configureApiKey(previewMode, widgetValue, params) {
+ return new Promise(resolve => {
+ this.trigger_up('gmap_api_key_request', {
+ editableMode: true,
+ reconfigure: true,
+ onSuccess: () => resolve(),
+ });
+ });
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ async customizeBodyBgType(previewMode, widgetValue, params) {
+ if (widgetValue === 'NONE') {
+ this.bodyImageType = 'image';
+ return this.customizeBodyBg(previewMode, '', params);
+ }
+ // TODO improve: hack to click on external image picker
+ this.bodyImageType = widgetValue;
+ const widget = this._requestUserValueWidgets(params.imagepicker)[0];
+ widget.enable();
+ },
+ /**
+ * @override
+ */
+ async customizeBodyBg(previewMode, widgetValue, params) {
+ // TODO improve: customize two variables at the same time...
+ await this.customizeWebsiteVariable(previewMode, this.bodyImageType, {variable: 'body-image-type'});
+ await this.customizeWebsiteVariable(previewMode, widgetValue ? `'${widgetValue}'` : '', {variable: 'body-image'});
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ async openCustomCodeDialog(previewMode, widgetValue, params) {
+ const libsProm = this._loadLibs({
+ jsLibs: [
+ '/web/static/lib/ace/ace.js',
+ '/web/static/lib/ace/mode-xml.js',
+ ],
+ });
+
+ let websiteId;
+ this.trigger_up('context_get', {
+ callback: (ctx) => {
+ websiteId = ctx['website_id'];
+ },
+ });
+
+ let website;
+ const dataProm = this._rpc({
+ model: 'website',
+ method: 'read',
+ args: [[websiteId], ['custom_code_head', 'custom_code_footer']],
+ }).then(websites => {
+ website = websites[0];
+ });
+
+ let fieldName, title, contentText;
+ if (widgetValue === 'head') {
+ fieldName = 'custom_code_head';
+ title = _t('Custom head code');
+ contentText = _t('Enter code that will be added into the <head> of every page of your site.');
+ } else {
+ fieldName = 'custom_code_footer';
+ title = _t('Custom end of body code');
+ contentText = _t('Enter code that will be added before the </body> of every page of your site.');
+ }
+
+ await Promise.all([libsProm, dataProm]);
+
+ await new Promise(resolve => {
+ const $content = $(core.qweb.render('website.custom_code_dialog_content', {
+ contentText,
+ }));
+ const aceEditor = this._renderAceEditor($content.find('.o_ace_editor_container')[0], website[fieldName] || '');
+ const dialog = new Dialog(this, {
+ title,
+ $content,
+ buttons: [
+ {
+ text: _t("Save"),
+ classes: 'btn-primary',
+ click: async () => {
+ await this._rpc({
+ model: 'website',
+ method: 'write',
+ args: [
+ [websiteId],
+ {[fieldName]: aceEditor.getValue()},
+ ],
+ });
+ },
+ close: true,
+ },
+ {
+ text: _t("Discard"),
+ close: true,
+ },
+ ],
+ });
+ dialog.on('closed', this, resolve);
+ dialog.open();
+ });
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ async switchTheme(previewMode, widgetValue, params) {
+ const save = await new Promise(resolve => {
+ Dialog.confirm(this, _t("Changing theme requires to leave the editor. This will save all your changes, are you sure you want to proceed? Be careful that changing the theme will reset all your color customizations."), {
+ confirm_callback: () => resolve(true),
+ cancel_callback: () => resolve(false),
+ });
+ });
+ if (!save) {
+ return;
+ }
+ this.trigger_up('request_save', {
+ reload: false,
+ onSuccess: () => window.location.href = '/web#action=website.theme_install_kanban_action',
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async _checkIfWidgetsUpdateNeedWarning(widgets) {
+ const warningMessage = await this._super(...arguments);
+ if (warningMessage) {
+ return warningMessage;
+ }
+ for (const widget of widgets) {
+ if (widget.getMethodsNames().includes('customizeWebsiteVariable')
+ && widget.getMethodsParams('customizeWebsiteVariable').variable === 'color-palettes-number') {
+ const hasCustomizedColors = weUtils.getCSSVariableValue('has-customized-colors');
+ if (hasCustomizedColors && hasCustomizedColors !== 'false') {
+ return _t("Changing the color palette will reset all your color customizations, are you sure you want to proceed?");
+ }
+ }
+ }
+ return '';
+ },
+ /**
+ * @override
+ */
+ async _computeWidgetState(methodName, params) {
+ if (methodName === 'customizeBodyBgType') {
+ const bgImage = $('#wrapwrap').css('background-image');
+ if (bgImage === 'none') {
+ return "NONE";
+ }
+ return weUtils.getCSSVariableValue('body-image-type');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async _computeWidgetVisibility(widgetName, params) {
+ if (widgetName === 'body_bg_image_opt') {
+ return false;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ * @param {DOMElement} node
+ * @param {String} content text of the editor
+ * @returns {Object}
+ */
+ _renderAceEditor(node, content) {
+ const aceEditor = window.ace.edit(node);
+ aceEditor.setTheme('ace/theme/monokai');
+ aceEditor.setValue(content, 1);
+ aceEditor.setOptions({
+ minLines: 20,
+ maxLines: Infinity,
+ showPrintMargin: false,
+ });
+ aceEditor.renderer.setOptions({
+ highlightGutterLine: true,
+ showInvisibles: true,
+ fontSize: 14,
+ });
+
+ const aceSession = aceEditor.getSession();
+ aceSession.setOptions({
+ mode: "ace/mode/xml",
+ useWorker: false,
+ });
+ return aceEditor;
+ },
+ /**
+ * @override
+ */
+ async _renderCustomXML(uiFragment) {
+ uiFragment.querySelectorAll('we-colorpicker').forEach(el => {
+ el.dataset.lazyPalette = 'true';
+ });
+ },
+});
+
+options.registry.ThemeColors = options.registry.OptionsTab.extend({
+ /**
+ * @override
+ */
+ async start() {
+ // Checks for support of the old color system
+ const style = window.getComputedStyle(document.documentElement);
+ const supportOldColorSystem = weUtils.getCSSVariableValue('support-13-0-color-system', style) === 'true';
+ const hasCustomizedOldColorSystem = weUtils.getCSSVariableValue('has-customized-13-0-color-system', style) === 'true';
+ this._showOldColorSystemWarning = supportOldColorSystem && hasCustomizedOldColorSystem;
+
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async updateUIVisibility() {
+ await this._super(...arguments);
+ const oldColorSystemEl = this.el.querySelector('.o_old_color_system_warning');
+ oldColorSystemEl.classList.toggle('d-none', !this._showOldColorSystemWarning);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async _renderCustomXML(uiFragment) {
+ const paletteSelectorEl = uiFragment.querySelector('[data-variable="color-palettes-number"]');
+ const style = window.getComputedStyle(document.documentElement);
+ const nbPalettes = parseInt(weUtils.getCSSVariableValue('number-of-color-palettes', style));
+ for (let i = 1; i <= nbPalettes; i++) {
+ const btnEl = document.createElement('we-button');
+ btnEl.classList.add('o_palette_color_preview_button');
+ btnEl.dataset.customizeWebsiteVariable = i;
+ for (let c = 1; c <= 5; c++) {
+ const colorPreviewEl = document.createElement('span');
+ colorPreviewEl.classList.add('o_palette_color_preview');
+ const color = weUtils.getCSSVariableValue(`o-palette-${i}-o-color-${c}`, style);
+ colorPreviewEl.style.backgroundColor = color;
+ btnEl.appendChild(colorPreviewEl);
+ }
+ paletteSelectorEl.appendChild(btnEl);
+ }
+
+ for (let i = 1; i <= 5; i++) {
+ const collapseEl = document.createElement('we-collapse');
+ const ccPreviewEl = $(qweb.render('web_editor.color.combination.preview'))[0];
+ ccPreviewEl.classList.add('text-center', `o_cc${i}`);
+ collapseEl.appendChild(ccPreviewEl);
+ const editionEls = $(qweb.render('website.color_combination_edition', {number: i}));
+ for (const el of editionEls) {
+ collapseEl.appendChild(el);
+ }
+ uiFragment.appendChild(collapseEl);
+ }
+
+ await this._super(...arguments);
+ },
+});
+
+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.
+ *
+ * @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', 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();
+ },
+});
+
+options.registry.company_data = options.Class.extend({
+ /**
+ * Fetches data to determine the URL where the user can edit its company
+ * data. Saves the info in the prototype to do this only once.
+ *
+ * @override
+ */
+ start: function () {
+ var proto = options.registry.company_data.prototype;
+ var prom;
+ var self = this;
+ if (proto.__link === undefined) {
+ prom = this._rpc({route: '/web/session/get_session_info'}).then(function (session) {
+ return self._rpc({
+ model: 'res.users',
+ method: 'read',
+ args: [session.uid, ['company_id']],
+ });
+ }).then(function (res) {
+ proto.__link = '/web#action=base.action_res_company_form&view_type=form&id=' + (res && res[0] && res[0].company_id[0] || 1);
+ });
+ }
+ return Promise.all([this._super.apply(this, arguments), prom]);
+ },
+ /**
+ * When the users selects company data, opens a dialog to ask him if he
+ * wants to be redirected to the company form view to edit it.
+ *
+ * @override
+ */
+ onFocus: function () {
+ var self = this;
+ var proto = options.registry.company_data.prototype;
+
+ Dialog.confirm(this, _t("Do you want to edit the company data ?"), {
+ confirm_callback: function () {
+ self.trigger_up('request_save', {
+ reload: false,
+ onSuccess: function () {
+ window.location.href = proto.__link;
+ },
+ });
+ },
+ });
+ },
+});
+
+options.registry.Carousel = options.Class.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this.$target.carousel('pause');
+ this.$indicators = this.$target.find('.carousel-indicators');
+ this.$controls = this.$target.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators');
+
+ // Prevent enabling the carousel overlay when clicking on the carousel
+ // controls (indeed we want it to change the carousel slide then enable
+ // the slide overlay) + See "CarouselItem" option.
+ this.$controls.addClass('o_we_no_overlay');
+
+ let _slideTimestamp;
+ this.$target.on('slide.bs.carousel.carousel_option', () => {
+ _slideTimestamp = window.performance.now();
+ setTimeout(() => this.trigger_up('hide_overlay'));
+ });
+ this.$target.on('slid.bs.carousel.carousel_option', () => {
+ // slid.bs.carousel is most of the time fired too soon by bootstrap
+ // since it emulates the transitionEnd with a setTimeout. We wait
+ // here an extra 20% of the time before retargeting edition, which
+ // should be enough...
+ const _slideDuration = (window.performance.now() - _slideTimestamp);
+ setTimeout(() => {
+ this.trigger_up('activate_snippet', {
+ $snippet: this.$target.find('.carousel-item.active'),
+ ifInactiveOptions: true,
+ });
+ this.$target.trigger('active_slide_targeted');
+ }, 0.2 * _slideDuration);
+ });
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ this.$target.off('.carousel_option');
+ },
+ /**
+ * @override
+ */
+ onBuilt: function () {
+ this._assignUniqueID();
+ },
+ /**
+ * @override
+ */
+ onClone: function () {
+ this._assignUniqueID();
+ },
+ /**
+ * @override
+ */
+ cleanForSave: function () {
+ const $items = this.$target.find('.carousel-item');
+ $items.removeClass('next prev left right active').first().addClass('active');
+ this.$indicators.find('li').removeClass('active').empty().first().addClass('active');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Creates a unique ID for the carousel and reassign data-attributes that
+ * depend on it.
+ *
+ * @private
+ */
+ _assignUniqueID: function () {
+ const id = 'myCarousel' + Date.now();
+ this.$target.attr('id', id);
+ this.$target.find('[data-target]').attr('data-target', '#' + id);
+ _.each(this.$target.find('[data-slide], [data-slide-to]'), function (el) {
+ var $el = $(el);
+ if ($el.attr('data-target')) {
+ $el.attr('data-target', '#' + id);
+ } else if ($el.attr('href')) {
+ $el.attr('href', '#' + id);
+ }
+ });
+ },
+});
+
+options.registry.CarouselItem = options.Class.extend({
+ isTopOption: true,
+ forceNoDeleteButton: true,
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.$carousel = this.$target.closest('.carousel');
+ this.$indicators = this.$carousel.find('.carousel-indicators');
+ this.$controls = this.$carousel.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators');
+
+ var leftPanelEl = this.$overlay.data('$optionsSection')[0];
+ var titleTextEl = leftPanelEl.querySelector('we-title > span');
+ this.counterEl = document.createElement('span');
+ titleTextEl.appendChild(this.counterEl);
+
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super(...arguments);
+ this.$carousel.off('.carousel_item_option');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates the slide counter.
+ *
+ * @override
+ */
+ updateUI: async function () {
+ await this._super(...arguments);
+ const $items = this.$carousel.find('.carousel-item');
+ const $activeSlide = $items.filter('.active');
+ const updatedText = ` (${$activeSlide.index() + 1}/${$items.length})`;
+ this.counterEl.textContent = updatedText;
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds a slide.
+ *
+ * @see this.selectClass for parameters
+ */
+ addSlide: function (previewMode) {
+ const $items = this.$carousel.find('.carousel-item');
+ this.$controls.removeClass('d-none');
+ this.$indicators.append($('<li>', {
+ 'data-target': '#' + this.$carousel.attr('id'),
+ 'data-slide-to': $items.length,
+ }));
+ this.$indicators.append(' ');
+ // Need to remove editor data from the clone so it gets its own.
+ const $active = $items.filter('.active');
+ $active.clone(false)
+ .removeClass('active')
+ .insertAfter($active);
+ this.$carousel.carousel('next');
+ },
+ /**
+ * Removes the current slide.
+ *
+ * @see this.selectClass for parameters.
+ */
+ removeSlide: function (previewMode) {
+ const $items = this.$carousel.find('.carousel-item');
+ const newLength = $items.length - 1;
+ if (!this.removing && newLength > 0) {
+ const $toDelete = $items.filter('.active');
+ this.$carousel.one('active_slide_targeted.carousel_item_option', () => {
+ $toDelete.remove();
+ this.$indicators.find('li:last').remove();
+ this.$controls.toggleClass('d-none', newLength === 1);
+ this.$carousel.trigger('content_changed');
+ this.removing = false;
+ });
+ this.removing = true;
+ this.$carousel.carousel('prev');
+ }
+ },
+ /**
+ * Goes to next slide or previous slide.
+ *
+ * @see this.selectClass for parameters
+ */
+ slide: function (previewMode, widgetValue, params) {
+ switch (widgetValue) {
+ case 'left':
+ this.$controls.filter('.carousel-control-prev')[0].click();
+ break;
+ case 'right':
+ this.$controls.filter('.carousel-control-next')[0].click();
+ break;
+ }
+ },
+});
+
+options.registry.sizing_x = options.registry.sizing.extend({
+ /**
+ * @override
+ */
+ onClone: function (options) {
+ this._super.apply(this, arguments);
+ // Below condition is added to remove offset of target element only
+ // and not its children to avoid design alteration of a container/block.
+ if (options.isCurrent) {
+ var _class = this.$target.attr('class').replace(/\s*(offset-xl-|offset-lg-)([0-9-]+)/g, '');
+ this.$target.attr('class', _class);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getSize: function () {
+ var width = this.$target.closest('.row').width();
+ var gridE = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
+ var gridW = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
+ this.grid = {
+ e: [_.map(gridE, v => ('col-lg-' + v)), _.map(gridE, v => width / 12 * v), 'width'],
+ w: [_.map(gridW, v => ('offset-lg-' + v)), _.map(gridW, v => width / 12 * v), 'margin-left'],
+ };
+ return this.grid;
+ },
+ /**
+ * @override
+ */
+ _onResize: function (compass, beginClass, current) {
+ if (compass === 'w') {
+ // don't change the right border position when we change the offset (replace col size)
+ var beginCol = Number(beginClass.match(/col-lg-([0-9]+)|$/)[1] || 0);
+ var beginOffset = Number(beginClass.match(/offset-lg-([0-9-]+)|$/)[1] || beginClass.match(/offset-xl-([0-9-]+)|$/)[1] || 0);
+ var offset = Number(this.grid.w[0][current].match(/offset-lg-([0-9-]+)|$/)[1] || 0);
+ if (offset < 0) {
+ offset = 0;
+ }
+ var colSize = beginCol - (offset - beginOffset);
+ if (colSize <= 0) {
+ colSize = 1;
+ offset = beginOffset + beginCol - 1;
+ }
+ this.$target.attr('class', this.$target.attr('class').replace(/\s*(offset-xl-|offset-lg-|col-lg-)([0-9-]+)/g, ''));
+
+ this.$target.addClass('col-lg-' + (colSize > 12 ? 12 : colSize));
+ if (offset > 0) {
+ this.$target.addClass('offset-lg-' + offset);
+ }
+ }
+ this._super.apply(this, arguments);
+ },
+});
+
+options.registry.layout_column = options.Class.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ // Needs to be done manually for now because _computeWidgetVisibility
+ // doesn't go through this option for buttons inside of a select.
+ // TODO: improve this.
+ this.$el.find('we-button[data-name="zero_cols_opt"]')
+ .toggleClass('d-none', !this.$target.is('.s_allow_columns'));
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Changes the number of columns.
+ *
+ * @see this.selectClass for parameters
+ */
+ selectCount: async function (previewMode, widgetValue, params) {
+ const previousNbColumns = this.$('> .row').children().length;
+ let $row = this.$('> .row');
+ if (!$row.length) {
+ $row = this.$target.contents().wrapAll($('<div class="row"><div class="col-lg-12"/></div>')).parent().parent();
+ }
+
+ const nbColumns = parseInt(widgetValue);
+ await this._updateColumnCount($row, (nbColumns || 1) - $row.children().length);
+ // Yield UI thread to wait for event to bubble before activate_snippet is called.
+ // In this case this lets the select handle the click event before we switch snippet.
+ // TODO: make this more generic in activate_snippet event handler.
+ await new Promise(resolve => setTimeout(resolve));
+ if (nbColumns === 0) {
+ $row.contents().unwrap().contents().unwrap();
+ this.trigger_up('activate_snippet', {$snippet: this.$target});
+ } else if (previousNbColumns === 0) {
+ this.trigger_up('activate_snippet', {$snippet: this.$('> .row').children().first()});
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ if (methodName === 'selectCount') {
+ return this.$('> .row').children().length;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Adds new columns which are clones of the last column or removes the
+ * last x columns.
+ *
+ * @private
+ * @param {jQuery} $row - the row in which to update the columns
+ * @param {integer} count - positif to add, negative to remove
+ */
+ _updateColumnCount: async function ($row, count) {
+ if (!count) {
+ return;
+ }
+
+ if (count > 0) {
+ var $lastColumn = $row.children().last();
+ for (var i = 0; i < count; i++) {
+ await new Promise(resolve => {
+ this.trigger_up('clone_snippet', {$snippet: $lastColumn, onSuccess: resolve});
+ });
+ }
+ } else {
+ var self = this;
+ for (const el of $row.children().slice(count)) {
+ await new Promise(resolve => {
+ self.trigger_up('remove_snippet', {$snippet: $(el), onSuccess: resolve});
+ });
+ }
+ }
+
+ this._resizeColumns($row.children());
+ this.trigger_up('cover_update');
+ },
+ /**
+ * Resizes the columns so that they are kept on one row.
+ *
+ * @private
+ * @param {jQuery} $columns - the columns to resize
+ */
+ _resizeColumns: function ($columns) {
+ const colsLength = $columns.length;
+ var colSize = Math.floor(12 / colsLength) || 1;
+ var colOffset = Math.floor((12 - colSize * colsLength) / 2);
+ var colClass = 'col-lg-' + colSize;
+ _.each($columns, function (column) {
+ var $column = $(column);
+ $column.attr('class', $column.attr('class').replace(/\b(col|offset)-lg(-\d+)?\b/g, ''));
+ $column.addClass(colClass);
+ });
+ if (colOffset) {
+ $columns.first().addClass('offset-lg-' + colOffset);
+ }
+ },
+});
+
+options.registry.Parallax = options.Class.extend({
+ /**
+ * @override
+ */
+ async start() {
+ this.parallaxEl = this.$target.find('> .s_parallax_bg')[0] || null;
+ this._updateBackgroundOptions();
+
+ this.$target.on('content_changed.ParallaxOption', this._onExternalUpdate.bind(this));
+
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ onFocus() {
+ // Refresh the parallax animation on focus; at least useful because
+ // there may have been changes in the page that influenced the parallax
+ // rendering (new snippets, ...).
+ // TODO make this automatic.
+ if (this.parallaxEl) {
+ this._refreshPublicWidgets();
+ }
+ },
+ /**
+ * @override
+ */
+ onMove() {
+ this._refreshPublicWidgets();
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ this.$target.off('.ParallaxOption');
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Build/remove parallax.
+ *
+ * @see this.selectClass for parameters
+ */
+ async selectDataAttribute(previewMode, widgetValue, params) {
+ await this._super(...arguments);
+ if (params.attributeName !== 'scrollBackgroundRatio') {
+ return;
+ }
+
+ const isParallax = (widgetValue !== '0');
+ this.$target.toggleClass('parallax', isParallax);
+ this.$target.toggleClass('s_parallax_is_fixed', widgetValue === '1');
+ this.$target.toggleClass('s_parallax_no_overflow_hidden', (widgetValue === '0' || widgetValue === '1'));
+ if (isParallax) {
+ if (!this.parallaxEl) {
+ this.parallaxEl = document.createElement('span');
+ this.parallaxEl.classList.add('s_parallax_bg');
+ this.$target.prepend(this.parallaxEl);
+ }
+ } else {
+ if (this.parallaxEl) {
+ this.parallaxEl.remove();
+ this.parallaxEl = null;
+ }
+ }
+
+ this._updateBackgroundOptions();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async _computeVisibility(widgetName) {
+ return !this.$target.hasClass('o_background_video');
+ },
+ /**
+ * @override
+ */
+ async _computeWidgetState(methodName, params) {
+ if (methodName === 'selectDataAttribute' && params.parallaxTypeOpt) {
+ const attrName = params.attributeName;
+ const attrValue = (this.$target[0].dataset[attrName] || params.attributeDefaultValue).trim();
+ switch (attrValue) {
+ case '0':
+ case '1': {
+ return attrValue;
+ }
+ default: {
+ return (attrValue.startsWith('-') ? '-1.5' : '1.5');
+ }
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Updates external background-related option to work with the parallax
+ * element instead of the original target when necessary.
+ *
+ * @private
+ */
+ _updateBackgroundOptions() {
+ this.trigger_up('option_update', {
+ optionNames: ['BackgroundImage', 'BackgroundPosition', 'BackgroundOptimize'],
+ name: 'target',
+ data: this.parallaxEl ? $(this.parallaxEl) : this.$target,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called on any snippet update to check if the parallax should still be
+ * enabled or not.
+ *
+ * TODO there is probably a better system to implement to solve this issue.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onExternalUpdate(ev) {
+ if (!this.parallaxEl) {
+ return;
+ }
+ const bgImage = this.parallaxEl.style.backgroundImage;
+ if (!bgImage || bgImage === 'none' || this.$target.hasClass('o_background_video')) {
+ // The parallax option was enabled but the background image was
+ // removed: disable the parallax option.
+ const widget = this._requestUserValueWidgets('parallax_none_opt')[0];
+ widget.enable();
+ widget.getParent().close(); // FIXME remove this ugly hack asap
+ }
+ },
+});
+
+options.registry.collapse = options.Class.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.$target.on('shown.bs.collapse hidden.bs.collapse', '[role="tabpanel"]', function () {
+ self.trigger_up('cover_update');
+ self.$target.trigger('content_changed');
+ });
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ onBuilt: function () {
+ this._createIDs();
+ },
+ /**
+ * @override
+ */
+ onClone: function () {
+ this._createIDs();
+ },
+ /**
+ * @override
+ */
+ onMove: function () {
+ this._createIDs();
+ var $panel = this.$target.find('.collapse').removeData('bs.collapse');
+ if ($panel.attr('aria-expanded') === 'true') {
+ $panel.closest('.accordion').find('.collapse[aria-expanded="true"]')
+ .filter((i, el) => (el !== $panel[0]))
+ .collapse('hide')
+ .one('hidden.bs.collapse', function () {
+ $panel.trigger('shown.bs.collapse');
+ });
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Associates unique ids on collapse elements.
+ *
+ * @private
+ */
+ _createIDs: function () {
+ let time = new Date().getTime();
+ const $tablist = this.$target.closest('[role="tablist"]');
+ const $tab = this.$target.find('[role="tab"]');
+ const $panel = this.$target.find('[role="tabpanel"]');
+
+ const setUniqueId = ($elem, label) => {
+ let elemId = $elem.attr('id');
+ if (!elemId || $('[id="' + elemId + '"]').length > 1) {
+ do {
+ time++;
+ elemId = label + time;
+ } while ($('#' + elemId).length);
+ $elem.attr('id', elemId);
+ }
+ return elemId;
+ };
+
+ const tablistId = setUniqueId($tablist, 'myCollapse');
+ $panel.attr('data-parent', '#' + tablistId);
+ $panel.data('parent', '#' + tablistId);
+
+ const panelId = setUniqueId($panel, 'myCollapseTab');
+ $tab.attr('data-target', '#' + panelId);
+ $tab.data('target', '#' + panelId);
+ },
+});
+
+options.registry.HeaderNavbar = options.Class.extend({
+ /**
+ * Particular case: we want the option to be associated on the header navbar
+ * in XML so that the related options only appear on navbar click (not
+ * header), in a different section, etc... but we still want the target to
+ * be the header itself.
+ *
+ * @constructor
+ */
+ init() {
+ this._super(...arguments);
+ // Don't use setTarget, we want it to be set directly at initialization.
+ this.$target = this.$target.closest('#wrapwrap > header');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Needs to be done manually for now because data-dependencies
+ * doesn't work with "AND" conditions.
+ * TODO: improve this.
+ *
+ * @override
+ */
+ async _computeWidgetVisibility(widgetName, params) {
+ if (widgetName === 'option_logo_height_scrolled') {
+ return !this.$('.navbar-brand').hasClass('d-none');
+ }
+ return this._super(...arguments);
+ },
+});
+
+const VisibilityPageOptionUpdate = options.Class.extend({
+ pageOptionName: undefined,
+ showOptionWidgetName: undefined,
+ shownValue: '',
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+ const shown = await this._isShown();
+ this.trigger_up('snippet_option_visibility_update', {show: shown});
+ },
+ /**
+ * @override
+ */
+ async onTargetShow() {
+ if (await this._isShown()) {
+ // onTargetShow may be called even if the element is already shown.
+ // In most cases, this is not a problem but here it is as the code
+ // that follows clicks on the visibility checkbox regardless of its
+ // status. This avoids searching for that checkbox entirely.
+ return;
+ }
+ // TODO improve: here we make a hack so that if we make the invisible
+ // header appear for edition, its actual visibility for the page is
+ // toggled (otherwise it would be about editing an element which
+ // is actually never displayed on the page).
+ const widget = this._requestUserValueWidgets(this.showOptionWidgetName)[0];
+ widget.enable();
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for params
+ */
+ async visibility(previewMode, widgetValue, params) {
+ const show = (widgetValue !== 'hidden');
+ await new Promise(resolve => {
+ this.trigger_up('action_demand', {
+ actionName: 'toggle_page_option',
+ params: [{name: this.pageOptionName, value: show}],
+ onSuccess: () => resolve(),
+ });
+ });
+ this.trigger_up('snippet_option_visibility_update', {show: show});
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async _computeWidgetState(methodName, params) {
+ if (methodName === 'visibility') {
+ const shown = await this._isShown();
+ return shown ? this.shownValue : 'hidden';
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ async _isShown() {
+ return new Promise(resolve => {
+ this.trigger_up('action_demand', {
+ actionName: 'get_page_option',
+ params: [this.pageOptionName],
+ onSuccess: v => resolve(!!v),
+ });
+ });
+ },
+});
+
+options.registry.TopMenuVisibility = VisibilityPageOptionUpdate.extend({
+ pageOptionName: 'header_visible',
+ showOptionWidgetName: 'regular_header_visibility_opt',
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handles the switching between 3 differents visibilities of the header.
+ *
+ * @see this.selectClass for params
+ */
+ async visibility(previewMode, widgetValue, params) {
+ await this._super(...arguments);
+ await this._changeVisibility(widgetValue);
+ // TODO this is hacky but changing the header visibility may have an
+ // effect on features like FullScreenHeight which depend on viewport
+ // size so we simulate a resize.
+ $(window).trigger('resize');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async _changeVisibility(widgetValue) {
+ const show = (widgetValue !== 'hidden');
+ if (!show) {
+ return;
+ }
+ const transparent = (widgetValue === 'transparent');
+ await new Promise(resolve => {
+ this.trigger_up('action_demand', {
+ actionName: 'toggle_page_option',
+ params: [{name: 'header_overlay', value: transparent}],
+ onSuccess: () => resolve(),
+ });
+ });
+ if (!transparent) {
+ return;
+ }
+ await new Promise(resolve => {
+ this.trigger_up('action_demand', {
+ actionName: 'toggle_page_option',
+ params: [{name: 'header_color', value: ''}],
+ onSuccess: () => resolve(),
+ });
+ });
+ },
+ /**
+ * @override
+ */
+ async _computeWidgetState(methodName, params) {
+ const _super = this._super.bind(this);
+ if (methodName === 'visibility') {
+ this.shownValue = await new Promise(resolve => {
+ this.trigger_up('action_demand', {
+ actionName: 'get_page_option',
+ params: ['header_overlay'],
+ onSuccess: v => resolve(v ? 'transparent' : 'regular'),
+ });
+ });
+ }
+ return _super(...arguments);
+ },
+});
+
+options.registry.topMenuColor = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ selectStyle(previewMode, widgetValue, params) {
+ this._super(...arguments);
+ const className = widgetValue ? (params.colorPrefix + widgetValue) : '';
+ this.trigger_up('action_demand', {
+ actionName: 'toggle_page_option',
+ params: [{name: 'header_color', value: className}],
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeVisibility: async function () {
+ const show = await this._super(...arguments);
+ if (!show) {
+ return false;
+ }
+ return new Promise(resolve => {
+ this.trigger_up('action_demand', {
+ actionName: 'get_page_option',
+ params: ['header_overlay'],
+ onSuccess: value => resolve(!!value),
+ });
+ });
+ },
+});
+
+/**
+ * Manage the visibility of snippets on mobile.
+ */
+options.registry.MobileVisibility = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Allows to show or hide the associated snippet in mobile display mode.
+ *
+ * @see this.selectClass for parameters
+ */
+ showOnMobile(previewMode, widgetValue, params) {
+ const classes = `d-none d-md-${this.$target.css('display')}`;
+ this.$target.toggleClass(classes, !widgetValue);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async _computeWidgetState(methodName, params) {
+ if (methodName === 'showOnMobile') {
+ const classList = [...this.$target[0].classList];
+ return classList.includes('d-none') &&
+ classList.some(className => className.startsWith('d-md-')) ? '' : 'true';
+ }
+ return await this._super(...arguments);
+ },
+});
+
+/**
+ * Hide/show footer in the current page.
+ */
+options.registry.HideFooter = VisibilityPageOptionUpdate.extend({
+ pageOptionName: 'footer_visible',
+ showOptionWidgetName: 'hide_footer_page_opt',
+ shownValue: 'shown',
+});
+
+/**
+ * Handles the edition of snippet's anchor name.
+ */
+options.registry.anchor = options.Class.extend({
+ isTopOption: true,
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ start: function () {
+ // Generate anchor and copy it to clipboard on click, show the tooltip on success
+ this.$button = this.$el.find('we-button');
+ const clipboard = new ClipboardJS(this.$button[0], {text: () => this._getAnchorLink()});
+ clipboard.on('success', () => {
+ const anchor = decodeURIComponent(this._getAnchorLink());
+ this.displayNotification({
+ type: 'success',
+ message: _.str.sprintf(_t("Anchor copied to clipboard<br>Link: %s"), anchor),
+ buttons: [{text: _t("Edit"), click: () => this.openAnchorDialog(), primary: true}],
+ });
+ });
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ onClone: function () {
+ this.$target.removeAttr('data-anchor');
+ this.$target.filter(':not(.carousel)').removeAttr('id');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+ /**
+ * @see this.selectClass for parameters
+ */
+ openAnchorDialog: function (previewMode, widgetValue, params) {
+ var self = this;
+ var buttons = [{
+ text: _t("Save & copy"),
+ classes: 'btn-primary',
+ click: function () {
+ var $input = this.$('.o_input_anchor_name');
+ var anchorName = self._text2Anchor($input.val());
+ if (self.$target[0].id === anchorName) {
+ // If the chosen anchor name is already the one used by the
+ // element, close the dialog and do nothing else
+ this.close();
+ return;
+ }
+
+ const alreadyExists = !!document.getElementById(anchorName);
+ this.$('.o_anchor_already_exists').toggleClass('d-none', !alreadyExists);
+ $input.toggleClass('is-invalid', alreadyExists);
+ if (!alreadyExists) {
+ self._setAnchorName(anchorName);
+ this.close();
+ self.$button[0].click();
+ }
+ },
+ }, {
+ text: _t("Discard"),
+ close: true,
+ }];
+ if (this.$target.attr('id')) {
+ buttons.push({
+ text: _t("Remove"),
+ classes: 'btn-link ml-auto',
+ icon: 'fa-trash',
+ close: true,
+ click: function () {
+ self._setAnchorName();
+ },
+ });
+ }
+ new Dialog(this, {
+ title: _t("Link Anchor"),
+ $content: $(qweb.render('website.dialog.anchorName', {
+ currentAnchor: decodeURIComponent(this.$target.attr('id')),
+ })),
+ buttons: buttons,
+ }).open();
+ },
+ /**
+ * @private
+ * @param {String} value
+ */
+ _setAnchorName: function (value) {
+ if (value) {
+ this.$target.attr({
+ 'id': value,
+ 'data-anchor': true,
+ });
+ } else {
+ this.$target.removeAttr('id data-anchor');
+ }
+ this.$target.trigger('content_changed');
+ },
+ /**
+ * Returns anchor text.
+ *
+ * @private
+ * @returns {string}
+ */
+ _getAnchorLink: function () {
+ if (!this.$target[0].id) {
+ const $titles = this.$target.find('h1, h2, h3, h4, h5, h6');
+ const title = $titles.length > 0 ? $titles[0].innerText : this.data.snippetName;
+ const anchorName = this._text2Anchor(title);
+ let n = '';
+ while (document.getElementById(anchorName + n)) {
+ n = (n || 1) + 1;
+ }
+ this._setAnchorName(anchorName + n);
+ }
+ return `${window.location.pathname}#${this.$target[0].id}`;
+ },
+ /**
+ * Creates a safe id/anchor from text.
+ *
+ * @private
+ * @param {string} text
+ * @returns {string}
+ */
+ _text2Anchor: function (text) {
+ return encodeURIComponent(text.trim().replace(/\s+/g, '-'));
+ },
+});
+
+/**
+ * Controls box properties.
+ */
+options.registry.Box = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for parameters
+ */
+ setShadow(previewMode, widgetValue, params) {
+ this.$target.toggleClass(params.shadowClass, !!widgetValue);
+ const defaultShadow = this._getDefaultShadow(widgetValue, params.shadowClass);
+ this.$target[0].style.setProperty('box-shadow', defaultShadow, 'important');
+ if (widgetValue === 'outset') {
+ // In this case, the shadowClass is enough
+ this.$target[0].style.setProperty('box-shadow', '');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ if (methodName === 'setShadow') {
+ const shadowValue = this.$target.css('box-shadow');
+ if (!shadowValue || shadowValue === 'none') {
+ return '';
+ }
+ return this.$target.css('box-shadow').includes('inset') ? 'inset' : 'outset';
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async _computeWidgetVisibility(widgetName, params) {
+ if (widgetName === 'fake_inset_shadow_opt') {
+ return false;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ * @param {string} type
+ * @param {string} shadowClass
+ * @returns {string}
+ */
+ _getDefaultShadow(type, shadowClass) {
+ const el = document.createElement('div');
+ if (type) {
+ el.classList.add(shadowClass);
+ }
+ document.body.appendChild(el);
+ switch (type) {
+ case 'outset': {
+ return $(el).css('box-shadow');
+ }
+ case 'inset': {
+ return $(el).css('box-shadow') + ' inset';
+ }
+ }
+ el.remove();
+ return '';
+ }
+});
+
+options.registry.HeaderBox = options.registry.Box.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async selectStyle(previewMode, widgetValue, params) {
+ if ((params.variable || params.color)
+ && ['border-width', 'border-style', 'border-color', 'border-radius', 'box-shadow'].includes(params.cssProperty)) {
+ if (previewMode) {
+ return;
+ }
+ if (params.cssProperty === 'border-color') {
+ return this.customizeWebsiteColor(previewMode, widgetValue, params);
+ }
+ return this.customizeWebsiteVariable(previewMode, widgetValue, params);
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async setShadow(previewMode, widgetValue, params) {
+ if (params.variable) {
+ if (previewMode) {
+ return;
+ }
+ const defaultShadow = this._getDefaultShadow(widgetValue, params.shadowClass);
+ return this.customizeWebsiteVariable(previewMode, defaultShadow || 'none', params);
+ }
+ return this._super(...arguments);
+ },
+});
+
+options.registry.CookiesBar = options.registry.SnippetPopup.extend({
+ xmlDependencies: (options.registry.SnippetPopup.prototype.xmlDependencies || []).concat(
+ ['/website/static/src/xml/website.cookies_bar.xml']
+ ),
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Change the cookies bar layout.
+ *
+ * @see this.selectClass for parameters
+ */
+ selectLayout: function (previewMode, widgetValue, params) {
+ let websiteId;
+ this.trigger_up('context_get', {
+ callback: function (ctx) {
+ websiteId = ctx['website_id'];
+ },
+ });
+
+ const $template = $(qweb.render(`website.cookies_bar.${widgetValue}`, {
+ websiteId: websiteId,
+ }));
+
+ const $content = this.$target.find('.modal-content');
+ const selectorsToKeep = [
+ '.o_cookies_bar_text_button',
+ '.o_cookies_bar_text_policy',
+ '.o_cookies_bar_text_title',
+ '.o_cookies_bar_text_primary',
+ '.o_cookies_bar_text_secondary',
+ ];
+
+ if (this.$savedSelectors === undefined) {
+ this.$savedSelectors = [];
+ }
+
+ for (const selector of selectorsToKeep) {
+ const $currentLayoutEls = $content.find(selector).contents();
+ const $newLayoutEl = $template.find(selector);
+ if ($currentLayoutEls.length) {
+ // save value before change, eg 'title' is not inside 'discrete' template
+ // but we want to preserve it in case of select another layout later
+ this.$savedSelectors[selector] = $currentLayoutEls;
+ }
+ const $savedSelector = this.$savedSelectors[selector];
+ if ($newLayoutEl.length && $savedSelector && $savedSelector.length) {
+ $newLayoutEl.empty().append($savedSelector);
+ }
+ }
+
+ $content.empty().append($template);
+ },
+});
+
+/**
+ * Allows edition of 'cover_properties' in website models which have such
+ * fields (blogs, posts, events, ...).
+ */
+options.registry.CoverProperties = options.Class.extend({
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ this.$image = this.$target.find('.o_record_cover_image');
+ this.$filter = this.$target.find('.o_record_cover_filter');
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$filterValueOpts = this.$el.find('[data-filter-value]');
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handles a background change.
+ *
+ * @see this.selectClass for parameters
+ */
+ background: async function (previewMode, widgetValue, params) {
+ if (widgetValue === '') {
+ this.$image.css('background-image', '');
+ this.$target.removeClass('o_record_has_cover');
+ } else {
+ this.$image.css('background-image', `url('${widgetValue}')`);
+ this.$target.addClass('o_record_has_cover');
+ const $defaultSizeBtn = this.$el.find('.o_record_cover_opt_size_default');
+ $defaultSizeBtn.click();
+ $defaultSizeBtn.closest('we-select').click();
+ }
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ filterValue: function (previewMode, widgetValue, params) {
+ this.$filter.css('opacity', widgetValue || 0);
+ this.$filter.toggleClass('oe_black', parseFloat(widgetValue) !== 0);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ updateUI: async function () {
+ await this._super(...arguments);
+
+ // TODO: `o_record_has_cover` should be handled using model field, not
+ // resize_class to avoid all of this.
+ let coverClass = this.$el.find('[data-cover-opt-name="size"] we-button.active').data('selectClass') || '';
+ const bg = this.$image.css('background-image');
+ if (bg && bg !== 'none') {
+ coverClass += " o_record_has_cover";
+ }
+ // Update saving dataset
+ this.$target[0].dataset.coverClass = coverClass;
+ this.$target[0].dataset.textAlignClass = this.$el.find('[data-cover-opt-name="text_align"] we-button.active').data('selectClass') || '';
+ this.$target[0].dataset.filterValue = this.$filterValueOpts.filter('.active').data('filterValue') || 0.0;
+ let colorPickerWidget = null;
+ this.trigger_up('user_value_widget_request', {
+ name: 'bg_color_opt',
+ onSuccess: _widget => colorPickerWidget = _widget,
+ });
+ const color = colorPickerWidget._value;
+ const isCSSColor = ColorpickerWidget.isCSSColor(color);
+ this.$target[0].dataset.bgColorClass = isCSSColor ? '' : weUtils.computeColorClasses([color])[0];
+ this.$target[0].dataset.bgColorStyle = isCSSColor ? `background-color: ${color};` : '';
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'filterValue': {
+ return parseFloat(this.$filter.css('opacity')).toFixed(1);
+ }
+ case 'background': {
+ const background = this.$image.css('background-image');
+ if (background && background !== 'none') {
+ return background.match(/^url\(["']?(.+?)["']?\)$/)[1];
+ }
+ return '';
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _computeWidgetVisibility: function (widgetName, params) {
+ if (params.coverOptName) {
+ return this.$target.data(`use_${params.coverOptName}`) === 'True';
+ }
+ return this._super(...arguments);
+ },
+});
+
+options.registry.ContainerWidth = options.Class.extend({
+ /**
+ * @override
+ */
+ cleanForSave: function () {
+ this.$target.removeClass('o_container_preview');
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ selectClass: async function (previewMode, widgetValue, params) {
+ await this._super(...arguments);
+ if (previewMode === 'reset') {
+ this.$target.removeClass('o_container_preview');
+ } else if (previewMode) {
+ this.$target.addClass('o_container_preview');
+ }
+ },
+});
+
+/**
+ * Allows snippets to be moved before the preceding element or after the following.
+ */
+options.registry.SnippetMove = options.Class.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ var $buttons = this.$el.find('we-button');
+ var $overlayArea = this.$overlay.find('.o_overlay_move_options');
+ $overlayArea.prepend($buttons[0]);
+ $overlayArea.append($buttons[1]);
+
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ onFocus: function () {
+ // TODO improve this: hack to hide options section if snippet move is
+ // the only one.
+ const $allOptions = this.$el.parent();
+ if ($allOptions.find('we-customizeblock-option').length <= 1) {
+ $allOptions.addClass('d-none');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Moves the snippet around.
+ *
+ * @see this.selectClass for parameters
+ */
+ moveSnippet: function (previewMode, widgetValue, params) {
+ const isNavItem = this.$target[0].classList.contains('nav-item');
+ const $tabPane = isNavItem ? $(this.$target.find('.nav-link')[0].hash) : null;
+ switch (widgetValue) {
+ case 'prev':
+ this.$target.prev().before(this.$target);
+ if (isNavItem) {
+ $tabPane.prev().before($tabPane);
+ }
+ break;
+ case 'next':
+ this.$target.next().after(this.$target);
+ if (isNavItem) {
+ $tabPane.next().after($tabPane);
+ }
+ break;
+ }
+ if (params.name === 'move_up_opt' || params.name === 'move_down_opt') {
+ dom.scrollTo(this.$target[0], {
+ extraOffset: 50,
+ easing: 'linear',
+ duration: 550,
+ });
+ }
+ },
+});
+
+options.registry.ScrollButton = options.Class.extend({
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+ this.$button = this.$('.o_scroll_button');
+ },
+ /**
+ * Removes button if the option is not displayed (for example in "fit
+ * content" height).
+ *
+ * @override
+ */
+ updateUIVisibility: async function () {
+ await this._super(...arguments);
+ if (this.$button.length && this.el.offsetParent === null) {
+ this.$button.detach();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Toggles the scroll down button.
+ */
+ toggleButton: function (previewMode, widgetValue, params) {
+ if (widgetValue) {
+ if (!this.$button.length) {
+ const anchor = document.createElement('a');
+ anchor.classList.add(
+ 'o_scroll_button',
+ 'mb-3',
+ 'rounded-circle',
+ 'align-items-center',
+ 'justify-content-center',
+ 'mx-auto',
+ 'bg-primary',
+ );
+ anchor.href = '#';
+ anchor.contentEditable = "false";
+ anchor.title = _t("Scroll down to next section");
+ const arrow = document.createElement('i');
+ arrow.classList.add('fa', 'fa-angle-down', 'fa-3x');
+ anchor.appendChild(arrow);
+ this.$button = $(anchor);
+ }
+ this.$target.append(this.$button);
+ } else {
+ this.$button.detach();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'toggleButton':
+ return !!this.$button.parent().length;
+ }
+ return this._super(...arguments);
+ },
+});
+
+return {
+ UrlPickerUserValueWidget: UrlPickerUserValueWidget,
+ FontFamilyPickerUserValueWidget: FontFamilyPickerUserValueWidget,
+};
+});
diff --git a/addons/website/static/src/js/editor/widget_link.js b/addons/website/static/src/js/editor/widget_link.js
new file mode 100644
index 00000000..de3c1d9c
--- /dev/null
+++ b/addons/website/static/src/js/editor/widget_link.js
@@ -0,0 +1,104 @@
+odoo.define('website.editor.link', function (require) {
+'use strict';
+
+var weWidgets = require('wysiwyg.widgets');
+var wUtils = require('website.utils');
+
+weWidgets.LinkDialog.include({
+ xmlDependencies: (weWidgets.LinkDialog.prototype.xmlDependencies || []).concat(
+ ['/website/static/src/xml/website.editor.xml']
+ ),
+ events: _.extend({}, weWidgets.LinkDialog.prototype.events || {}, {
+ 'change select[name="link_anchor"]': '_onAnchorChange',
+ 'input input[name="url"]': '_onURLInput',
+ }),
+ custom_events: _.extend({}, weWidgets.LinkDialog.prototype.custom_events || {}, {
+ website_url_chosen: '_onAutocompleteClose',
+ }),
+ LINK_DEBOUNCE: 1000,
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._adaptPageAnchor = _.debounce(this._adaptPageAnchor, this.LINK_DEBOUNCE);
+ },
+ /**
+ * Allows the URL input to propose existing website pages.
+ *
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+ wUtils.autocompleteWithPages(this, this.$('input[name="url"]'));
+ this.opened(this._adaptPageAnchor.bind(this));
+ return def;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adaptPageAnchor: function () {
+ var urlInputValue = this.$('input[name="url"]').val();
+ var $pageAnchor = this.$('.o_link_dialog_page_anchor');
+ var isFromWebsite = urlInputValue[0] === '/';
+ var $selectMenu = this.$('select[name="link_anchor"]');
+ var $anchorsLoading = this.$('.o_anchors_loading');
+
+ if ($selectMenu.data("anchor-for") !== urlInputValue) { // avoid useless query
+ $anchorsLoading.removeClass('d-none');
+ $pageAnchor.toggleClass('d-none', !isFromWebsite);
+ $selectMenu.empty();
+ wUtils.loadAnchors(urlInputValue).then(function (anchors) {
+ _.each(anchors, function (anchor) {
+ $selectMenu.append($('<option>', {text: anchor}));
+ });
+ always();
+ }).guardedCatch(always);
+ } else {
+ always();
+ }
+
+ function always() {
+ $anchorsLoading.addClass('d-none');
+ $selectMenu.prop("selectedIndex", -1);
+ }
+ $selectMenu.data("anchor-for", urlInputValue);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAutocompleteClose: function () {
+ this._onURLInput();
+ },
+ /**
+ * @private
+ */
+ _onAnchorChange: function () {
+ var anchorValue = this.$('[name="link_anchor"]').val();
+ var $urlInput = this.$('[name="url"]');
+ var urlInputValue = $urlInput.val();
+ if (urlInputValue.indexOf('#') > -1) {
+ urlInputValue = urlInputValue.substr(0, urlInputValue.indexOf('#'));
+ }
+ $urlInput.val(urlInputValue + anchorValue);
+ },
+ /**
+ * @override
+ */
+ _onURLInput: function () {
+ this._super.apply(this, arguments);
+ this._adaptPageAnchor();
+ },
+});
+});
diff --git a/addons/website/static/src/js/editor/wysiwyg_multizone.js b/addons/website/static/src/js/editor/wysiwyg_multizone.js
new file mode 100644
index 00000000..596d40db
--- /dev/null
+++ b/addons/website/static/src/js/editor/wysiwyg_multizone.js
@@ -0,0 +1,286 @@
+odoo.define('web_editor.wysiwyg.multizone', function (require) {
+'use strict';
+
+var Wysiwyg = require('web_editor.wysiwyg');
+var snippetsEditor = require('web_editor.snippet.editor');
+
+/**
+ * Show/hide the dropdowns associated to the given toggles and allows to wait
+ * for when it is fully shown/hidden.
+ *
+ * Note: this also takes care of the fact the 'toggle' method of bootstrap does
+ * not properly work in all cases.
+ *
+ * @param {jQuery} $toggles
+ * @param {boolean} [show]
+ * @returns {Promise<jQuery>}
+ */
+function toggleDropdown($toggles, show) {
+ return Promise.all(_.map($toggles, toggle => {
+ var $toggle = $(toggle);
+ var $dropdown = $toggle.parent();
+ var shown = $dropdown.hasClass('show');
+ if (shown === show) {
+ return;
+ }
+ var toShow = !shown;
+ return new Promise(resolve => {
+ $dropdown.one(
+ toShow ? 'shown.bs.dropdown' : 'hidden.bs.dropdown',
+ () => resolve()
+ );
+ $toggle.dropdown(toShow ? 'show' : 'hide');
+ });
+ })).then(() => $toggles);
+}
+
+/**
+ * HtmlEditor
+ * Intended to edit HTML content. This widget uses the Wysiwyg editor
+ * improved by odoo.
+ *
+ * class editable: o_editable
+ * class non editable: o_not_editable
+ *
+ */
+var WysiwygMultizone = Wysiwyg.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.options.toolbarHandler = $('#web_editor-top-edit');
+ this.options.saveElement = function ($el, context, withLang) {
+ var outerHTML = this._getEscapedElement($el).prop('outerHTML');
+ return self._saveElement(outerHTML, self.options.recordInfo, $el[0]);
+ };
+
+ // Mega menu initialization: handle dropdown openings by hand
+ var $megaMenuToggles = this.$('.o_mega_menu_toggle');
+ $megaMenuToggles.removeAttr('data-toggle').dropdown('dispose');
+ $megaMenuToggles.on('click.wysiwyg_multizone', ev => {
+ var $toggle = $(ev.currentTarget);
+
+ // Each time we toggle a dropdown, we will destroy the dropdown
+ // behavior afterwards to keep manual control of it
+ var dispose = ($els => $els.dropdown('dispose'));
+
+ // First hide all other mega menus
+ toggleDropdown($megaMenuToggles.not($toggle), false).then(dispose);
+
+ // Then toggle the clicked one
+ toggleDropdown($toggle)
+ .then(dispose)
+ .then($el => {
+ var isShown = $el.parent().hasClass('show');
+ this.editor.snippetsMenu.toggleMegaMenuSnippets(isShown);
+ });
+ });
+
+ // Ensure :blank oe_structure elements are in fact empty as ':blank'
+ // does not really work with all browsers.
+ for (const el of this.$('.oe_structure')) {
+ if (!el.innerHTML.trim()) {
+ el.innerHTML = '';
+ }
+ }
+
+ // TODO remove this code in master by migrating users who did not
+ // receive the XML change about the 'oe_structure_solo' class (the
+ // header original XML is now correct but we changed specs after
+ // release to not allow multi snippets drop zones in the header).
+ const $headerZones = this._getEditableArea().filter((i, el) => el.closest('header#top') !== null);
+ // oe_structure_multi to ease custo in stable
+ const selector = '.oe_structure[id*="oe_structure"]:not(.oe_structure_multi)';
+ $headerZones.find(selector).addBack(selector).addClass('oe_structure_solo');
+
+ return this._super.apply(this, arguments).then(() => {
+ // Showing Mega Menu snippets if one dropdown is already opened
+ if (this.$('.o_mega_menu').hasClass('show')) {
+ this.editor.snippetsMenu.toggleMegaMenuSnippets(true);
+ }
+ });
+ },
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ save: function () {
+ if (this.isDirty()) {
+ return this._restoreMegaMenus()
+ .then(() => this.editor.save(false))
+ .then(() => ({isDirty: true}));
+ } else {
+ return {isDirty: false};
+ }
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._restoreMegaMenus();
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _getEditableArea: function () {
+ return $(':o_editable');
+ },
+ /**
+ * @private
+ * @param {HTMLElement} editable
+ */
+ _saveCoverProperties: function (editable) {
+ var el = editable.closest('.o_record_cover_container');
+ if (!el) {
+ return;
+ }
+
+ var resModel = el.dataset.resModel;
+ var resID = parseInt(el.dataset.resId);
+ if (!resModel || !resID) {
+ throw new Error('There should be a model and id associated to the cover');
+ }
+
+ this.__savedCovers = this.__savedCovers || {};
+ this.__savedCovers[resModel] = this.__savedCovers[resModel] || [];
+
+ if (this.__savedCovers[resModel].includes(resID)) {
+ return;
+ }
+ this.__savedCovers[resModel].push(resID);
+
+ var cssBgImage = $(el.querySelector('.o_record_cover_image')).css('background-image');
+ var coverProps = {
+ 'background-image': cssBgImage.replace(/"/g, '').replace(window.location.protocol + "//" + window.location.host, ''),
+ 'background_color_class': el.dataset.bgColorClass,
+ 'background_color_style': el.dataset.bgColorStyle,
+ 'opacity': el.dataset.filterValue,
+ 'resize_class': el.dataset.coverClass,
+ 'text_align_class': el.dataset.textAlignClass,
+ };
+
+ return this._rpc({
+ model: resModel,
+ method: 'write',
+ args: [
+ resID,
+ {'cover_properties': JSON.stringify(coverProps)}
+ ],
+ });
+ },
+ /**
+ * Saves one (dirty) element of the page.
+ *
+ * @private
+ * @param {jQuery} $el - the element to save
+ * @param {Object} context - the context to use for the saving rpc
+ * @param {boolean} [withLang=false]
+ * false if the lang must be omitted in the context (saving "master"
+ * page element)
+ */
+ _saveElement: function (outerHTML, recordInfo, editable) {
+ var promises = [];
+
+ var $el = $(editable);
+
+ // Saving a view content
+ var viewID = $el.data('oe-id');
+ if (viewID) {
+ promises.push(this._rpc({
+ model: 'ir.ui.view',
+ method: 'save',
+ args: [
+ viewID,
+ outerHTML,
+ $el.data('oe-xpath') || null,
+ ],
+ context: recordInfo.context,
+ }));
+ }
+
+ // Saving mega menu options
+ if ($el.data('oe-field') === 'mega_menu_content') {
+ // On top of saving the mega menu content like any other field
+ // content, we must save the custom classes that were set on the
+ // menu itself.
+ // FIXME normally removing the 'show' class should not be necessary here
+ // TODO check that editor classes are removed here as well
+ var classes = _.without($el.attr('class').split(' '), 'dropdown-menu', 'o_mega_menu', 'show');
+ promises.push(this._rpc({
+ model: 'website.menu',
+ method: 'write',
+ args: [
+ [parseInt($el.data('oe-id'))],
+ {
+ 'mega_menu_classes': classes.join(' '),
+ },
+ ],
+ }));
+ }
+
+ // Saving cover properties on related model if any
+ var prom = this._saveCoverProperties(editable);
+ if (prom) {
+ promises.push(prom);
+ }
+
+ return Promise.all(promises);
+ },
+ /**
+ * Restores mega menu behaviors and closes them (important to do before
+ * saving otherwise they would be saved opened).
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _restoreMegaMenus: function () {
+ var $megaMenuToggles = this.$('.o_mega_menu_toggle');
+ $megaMenuToggles.off('.wysiwyg_multizone')
+ .attr('data-toggle', 'dropdown')
+ .dropdown({});
+ return toggleDropdown($megaMenuToggles, false);
+ },
+});
+
+snippetsEditor.Class.include({
+ /**
+ * @private
+ * @param {boolean} show
+ */
+ toggleMegaMenuSnippets: function (show) {
+ setTimeout(() => this._activateSnippet(false));
+ this._showMegaMenuSnippets = show;
+ this._filterSnippets();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _filterSnippets(search) {
+ this._super(...arguments);
+ if (!this._showMegaMenuSnippets) {
+ this.el.querySelector('#snippet_mega_menu').classList.add('d-none');
+ }
+ },
+ /**
+ * @override
+ */
+ _insertDropzone: function ($hook) {
+ var $hookParent = $hook.parent();
+ var $dropzone = this._super(...arguments);
+ $dropzone.attr('data-editor-message', $hookParent.attr('data-editor-message'));
+ $dropzone.attr('data-editor-sub-message', $hookParent.attr('data-editor-sub-message'));
+ return $dropzone;
+ },
+});
+
+return WysiwygMultizone;
+});
diff --git a/addons/website/static/src/js/editor/wysiwyg_multizone_translate.js b/addons/website/static/src/js/editor/wysiwyg_multizone_translate.js
new file mode 100644
index 00000000..978ac4d9
--- /dev/null
+++ b/addons/website/static/src/js/editor/wysiwyg_multizone_translate.js
@@ -0,0 +1,301 @@
+odoo.define('web_editor.wysiwyg.multizone.translate', function (require) {
+'use strict';
+
+var core = require('web.core');
+var webDialog = require('web.Dialog');
+var WysiwygMultizone = require('web_editor.wysiwyg.multizone');
+var rte = require('web_editor.rte');
+var Dialog = require('wysiwyg.widgets.Dialog');
+var websiteNavbarData = require('website.navbar');
+
+var _t = core._t;
+
+
+var RTETranslatorWidget = rte.Class.extend({
+ /**
+ * If the element holds a translation, saves it. Otherwise, fallback to the
+ * standard saving but with the lang kept.
+ *
+ * @override
+ */
+ _saveElement: function ($el, context, withLang) {
+ var self = this;
+ if ($el.data('oe-translation-id')) {
+ return this._rpc({
+ model: 'ir.translation',
+ method: 'save_html',
+ args: [
+ [+$el.data('oe-translation-id')],
+ this._getEscapedElement($el).html()
+ ],
+ context: context,
+ });
+ }
+ return this._super($el, context, withLang === undefined ? true : withLang);
+ },
+});
+
+var AttributeTranslateDialog = Dialog.extend({
+ /**
+ * @constructor
+ */
+ init: function (parent, options, node) {
+ this._super(parent, _.extend({
+ title: _t("Translate Attribute"),
+ buttons: [
+ {text: _t("Close"), classes: 'btn-primary', click: this.save}
+ ],
+ }, options || {}));
+ this.translation = $(node).data('translation');
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var $group = $('<div/>', {class: 'form-group'}).appendTo(this.$el);
+ _.each(this.translation, function (node, attr) {
+ var $node = $(node);
+ var $label = $('<label class="col-form-label"></label>').text(attr);
+ var $input = $('<input class="form-control"/>').val($node.html());
+ $input.on('change keyup', function () {
+ var value = $input.val();
+ $node.html(value).trigger('change', node);
+ $node.data('$node').attr($node.data('attribute'), value).trigger('translate');
+ $node.trigger('change');
+ });
+ $group.append($label).append($input);
+ });
+ return this._super.apply(this, arguments);
+ }
+});
+
+var WysiwygTranslate = WysiwygMultizone.extend({
+ custom_events: _.extend({}, WysiwygMultizone.prototype.custom_events || {}, {
+ ready_to_save: '_onSave',
+ rte_change: '_onChange',
+ }),
+
+ /**
+ * @override
+ * @param {string} options.lang
+ */
+ init: function (parent, options) {
+ this.lang = options.lang;
+ options.recordInfo = _.defaults({
+ context: {lang: this.lang}
+ }, options.recordInfo, options);
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ // Hacky way to keep the top editor toolbar in translate mode for now
+ this.$webEditorTopEdit = $('<div id="web_editor-top-edit"></div>').prependTo(document.body);
+ this.options.toolbarHandler = this.$webEditorTopEdit;
+ this.editor = new (this.Editor)(this, Object.assign({Editor: RTETranslatorWidget}, this.options));
+ this.$editor = this.editor.rte.editable();
+ var promise = this.editor.prependTo(this.$editor[0].ownerDocument.body);
+
+ return promise.then(function () {
+ self._relocateEditorBar();
+ var attrs = ['placeholder', 'title', 'alt'];
+ _.each(attrs, function (attr) {
+ self._getEditableArea().filter('[' + attr + '*="data-oe-translation-id="]').filter(':empty, input, select, textarea, img').each(function () {
+ var $node = $(this);
+ var translation = $node.data('translation') || {};
+ var trans = $node.attr(attr);
+ var match = trans.match(/<span [^>]*data-oe-translation-id="([0-9]+)"[^>]*>(.*)<\/span>/);
+ var $trans = $(trans).addClass('d-none o_editable o_editable_translatable_attribute').appendTo('body');
+ $trans.data('$node', $node).data('attribute', attr);
+
+ translation[attr] = $trans[0];
+ $node.attr(attr, match[2]);
+
+ var select2 = $node.data('select2');
+ if (select2) {
+ select2.blur();
+ $node.on('translate', function () {
+ select2.blur();
+ });
+ $node = select2.container.find('input');
+ }
+ $node.addClass('o_translatable_attribute').data('translation', translation);
+ });
+ });
+
+ self.translations = [];
+ self.$editables_attr = self._getEditableArea().filter('.o_translatable_attribute');
+ self.$editables_attribute = $('.o_editable_translatable_attribute');
+
+ self.$editables_attribute.on('change', function () {
+ self.trigger_up('rte_change', {target: this});
+ });
+
+ self._markTranslatableNodes();
+ });
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super(...arguments);
+ this.$webEditorTopEdit.remove();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {Boolean}
+ */
+ isDirty: function () {
+ return this._super() || this.$editables_attribute.hasClass('o_dirty');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Return the editable area.
+ *
+ * @override
+ * @returns {JQuery}
+ */
+ _getEditableArea: function () {
+ var $editables = this._super();
+ return $editables.add(this.$editables_attribute);
+ },
+ /**
+ * Return an object describing the linked record.
+ *
+ * @override
+ * @param {Object} options
+ * @returns {Object} {res_id, res_model, xpath}
+ */
+ _getRecordInfo: function (options) {
+ options = options || {};
+ var recordInfo = this._super(options);
+ var $editable = $(options.target).closest(this._getEditableArea());
+ if (!$editable.length) {
+ $editable = $(this._getFocusedEditable());
+ }
+ recordInfo.context.lang = this.lang;
+ recordInfo.translation_id = $editable.data('oe-translation-id')|0;
+ return recordInfo;
+ },
+ /**
+ * @override
+ * @returns {Object} the summernote configuration
+ */
+ _editorOptions: function () {
+ var options = this._super();
+ options.toolbar = [
+ // todo: hide this feature for field (data-oe-model)
+ ['font', ['bold', 'italic', 'underline', 'clear']],
+ ['fontsize', ['fontsize']],
+ ['color', ['color']],
+ // keep every time
+ ['history', ['undo', 'redo']],
+ ];
+ return options;
+ },
+ /**
+ * Called when text is edited -> make sure text is not messed up and mark
+ * the element as dirty.
+ *
+ * @override
+ * @param {Jquery Event} [ev]
+ */
+ _onChange: function (ev) {
+ var $node = $(ev.data.target);
+ if (!$node.length) {
+ return;
+ }
+ $node.find('div,p').each(function () { // remove P,DIV elements which might have been inserted because of copy-paste
+ var $p = $(this);
+ $p.after($p.html()).remove();
+ });
+ var trans = this._getTranlationObject($node[0]);
+ $node.toggleClass('o_dirty', trans.value !== $node.html().replace(/[ \t\n\r]+/, ' '));
+ },
+ /**
+ * Returns a translation object.
+ *
+ * @private
+ * @param {Node} node
+ * @returns {Object}
+ */
+ _getTranlationObject: function (node) {
+ var $node = $(node);
+ var id = +$node.data('oe-translation-id');
+ if (!id) {
+ id = $node.data('oe-model')+','+$node.data('oe-id')+','+$node.data('oe-field');
+ }
+ var trans = _.find(this.translations, function (trans) {
+ return trans.id === id;
+ });
+ if (!trans) {
+ this.translations.push(trans = {'id': id});
+ }
+ return trans;
+ },
+ /**
+ * @private
+ */
+ _markTranslatableNodes: function () {
+ var self = this;
+ this._getEditableArea().each(function () {
+ var $node = $(this);
+ var trans = self._getTranlationObject(this);
+ trans.value = (trans.value ? trans.value : $node.html() ).replace(/[ \t\n\r]+/, ' ');
+ });
+ this._getEditableArea().prependEvent('click.translator', function (ev) {
+ if (ev.ctrlKey || !$(ev.target).is(':o_editable')) {
+ return;
+ }
+ ev.preventDefault();
+ ev.stopPropagation();
+ });
+
+ // attributes
+
+ this.$editables_attr.each(function () {
+ var $node = $(this);
+ var translation = $node.data('translation');
+ _.each(translation, function (node, attr) {
+ var trans = self._getTranlationObject(node);
+ trans.value = (trans.value ? trans.value : $node.html() ).replace(/[ \t\n\r]+/, ' ');
+ $node.attr('data-oe-translation-state', (trans.state || 'to_translate'));
+ });
+ });
+
+ this.$editables_attr.prependEvent('mousedown.translator click.translator mouseup.translator', function (ev) {
+ if (ev.ctrlKey) {
+ return;
+ }
+ ev.preventDefault();
+ ev.stopPropagation();
+ if (ev.type !== 'mousedown') {
+ return;
+ }
+
+ new AttributeTranslateDialog(self, {}, ev.target).open();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ _onSave: function (ev) {
+ ev.stopPropagation();
+ },
+});
+
+return WysiwygTranslate;
+});
diff --git a/addons/website/static/src/js/menu/content.js b/addons/website/static/src/js/menu/content.js
new file mode 100644
index 00000000..d2dff980
--- /dev/null
+++ b/addons/website/static/src/js/menu/content.js
@@ -0,0 +1,1129 @@
+odoo.define('website.contentMenu', function (require) {
+'use strict';
+
+var Class = require('web.Class');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var time = require('web.time');
+var weWidgets = require('wysiwyg.widgets');
+var websiteNavbarData = require('website.navbar');
+var websiteRootData = require('website.root');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var PagePropertiesDialog = weWidgets.Dialog.extend({
+ template: 'website.pagesMenu.page_info',
+ xmlDependencies: weWidgets.Dialog.prototype.xmlDependencies.concat(
+ ['/website/static/src/xml/website.pageProperties.xml']
+ ),
+ events: _.extend({}, weWidgets.Dialog.prototype.events, {
+ 'keyup input#page_name': '_onNameChanged',
+ 'keyup input#page_url': '_onUrlChanged',
+ 'change input#create_redirect': '_onCreateRedirectChanged',
+ 'click input#visibility_password': '_onPasswordClicked',
+ 'change input#visibility_password': '_onPasswordChanged',
+ 'change select#visibility': '_onVisibilityChanged',
+ 'error.datetimepicker': '_onDateTimePickerError',
+ }),
+
+ /**
+ * @constructor
+ * @override
+ */
+ init: function (parent, page_id, options) {
+ var self = this;
+ var serverUrl = window.location.origin + '/';
+ var length_url = serverUrl.length;
+ var serverUrlTrunc = serverUrl;
+ if (length_url > 30) {
+ serverUrlTrunc = serverUrl.slice(0,14) + '..' + serverUrl.slice(-14);
+ }
+ this.serverUrl = serverUrl;
+ this.serverUrlTrunc = serverUrlTrunc;
+ this.current_page_url = window.location.pathname;
+ this.page_id = page_id;
+
+ var buttons = [
+ {text: _t("Save"), classes: 'btn-primary', click: this.save},
+ {text: _t("Discard"), classes: 'mr-auto', close: true},
+ ];
+ if (options.fromPageManagement) {
+ buttons.push({
+ text: _t("Go To Page"),
+ icon: 'fa-globe',
+ classes: 'btn-link',
+ click: function (e) {
+ window.location.href = '/' + self.page.url;
+ },
+ });
+ }
+ buttons.push({
+ text: _t("Duplicate Page"),
+ icon: 'fa-clone',
+ classes: 'btn-link',
+ click: function (e) {
+ // modal('hide') will break the rpc, so hide manually
+ this.$el.closest('.modal').addClass('d-none');
+ _clonePage.call(this, self.page_id);
+ },
+ });
+ buttons.push({
+ text: _t("Delete Page"),
+ icon: 'fa-trash',
+ classes: 'btn-link',
+ click: function (e) {
+ _deletePage.call(this, self.page_id, options.fromPageManagement);
+ },
+ });
+ this._super(parent, _.extend({}, {
+ title: _t("Page Properties"),
+ size: 'medium',
+ buttons: buttons,
+ }, options || {}));
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ var defs = [this._super.apply(this, arguments)];
+ var self = this;
+
+ defs.push(this._rpc({
+ model: 'website.page',
+ method: 'get_page_properties',
+ args: [this.page_id],
+ }).then(function (page) {
+ page.url = _.str.startsWith(page.url, '/') ? page.url.substring(1) : page.url;
+ page.hasSingleGroup = page.group_id !== undefined;
+ self.page = page;
+ }));
+
+ return Promise.all(defs);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+
+ var defs = [this._super.apply(this, arguments)];
+
+ this.$('.ask_for_redirect').addClass('d-none');
+ this.$('.redirect_type').addClass('d-none');
+ this.$('.warn_about_call').addClass('d-none');
+ if (this.page.visibility !== 'password') {
+ this.$('.show_visibility_password').addClass('d-none');
+ }
+ if (this.page.visibility !== 'restricted_group') {
+ this.$('.show_group_id').addClass('d-none');
+ }
+ this.autocompleteWithGroups(this.$('#group_id'));
+
+ defs.push(this._getPageDependencies(this.page_id)
+ .then(function (dependencies) {
+ var dep_text = [];
+ _.each(dependencies, function (value, index) {
+ if (value.length > 0) {
+ dep_text.push(value.length + ' ' + index.toLowerCase());
+ }
+ });
+ dep_text = dep_text.join(', ');
+ self.$('#dependencies_redirect').html(qweb.render('website.show_page_dependencies', { dependencies: dependencies, dep_text: dep_text }));
+ self.$('#dependencies_redirect [data-toggle="popover"]').popover({
+ container: 'body',
+ });
+ }));
+
+ defs.push(this._getSupportedMimetype()
+ .then(function (mimetypes) {
+ self.supportedMimetype = mimetypes;
+ }));
+
+ defs.push(this._getPageKeyDependencies(this.page_id)
+ .then(function (dependencies) {
+ var dep_text = [];
+ _.each(dependencies, function (value, index) {
+ if (value.length > 0) {
+ dep_text.push(value.length + ' ' + index.toLowerCase());
+ }
+ });
+ dep_text = dep_text.join(', ');
+ self.$('.warn_about_call').html(qweb.render('website.show_page_key_dependencies', {dependencies: dependencies, dep_text: dep_text}));
+ self.$('.warn_about_call [data-toggle="popover"]').popover({
+ container: 'body',
+ });
+ }));
+
+ defs.push(this._rpc({model: 'res.users',
+ method: 'has_group',
+ args: ['website.group_multi_website']})
+ .then(function (has_group) {
+ if (!has_group) {
+ self.$('#website_restriction').addClass('hidden');
+ }
+ }));
+
+ var datepickersOptions = {
+ minDate: moment({ y: 1000 }),
+ maxDate: moment().add(200, 'y'),
+ calendarWeeks: true,
+ icons : {
+ time: 'fa fa-clock-o',
+ date: 'fa fa-calendar',
+ next: 'fa fa-chevron-right',
+ previous: 'fa fa-chevron-left',
+ up: 'fa fa-chevron-up',
+ down: 'fa fa-chevron-down',
+ },
+ locale : moment.locale(),
+ format : time.getLangDatetimeFormat(),
+ widgetPositioning : {
+ horizontal: 'auto',
+ vertical: 'top',
+ },
+ widgetParent: 'body',
+ };
+ if (this.page.date_publish) {
+ datepickersOptions.defaultDate = time.str_to_datetime(this.page.date_publish);
+ }
+ this.$('#date_publish_container').datetimepicker(datepickersOptions);
+ return Promise.all(defs);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ $('.popover').popover('hide');
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function (data) {
+ var self = this;
+ var context;
+ this.trigger_up('context_get', {
+ callback: function (ctx) {
+ context = ctx;
+ },
+ });
+ var url = this.$('#page_url').val();
+
+ var $datePublish = this.$("#date_publish");
+ $datePublish.closest(".form-group").removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ var datePublish = $datePublish.val();
+ if (datePublish !== "") {
+ datePublish = this._parse_date(datePublish);
+ if (!datePublish) {
+ $datePublish.closest(".form-group").addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ return;
+ }
+ }
+ var params = {
+ id: this.page.id,
+ name: this.$('#page_name').val(),
+ // Replace duplicate following '/' by only one '/'
+ url: url.replace(/\/{2,}/g, '/'),
+ is_menu: this.$('#is_menu').prop('checked'),
+ is_homepage: this.$('#is_homepage').prop('checked'),
+ website_published: this.$('#is_published').prop('checked'),
+ create_redirect: this.$('#create_redirect').prop('checked'),
+ redirect_type: this.$('#redirect_type').val(),
+ website_indexed: this.$('#is_indexed').prop('checked'),
+ visibility: this.$('#visibility').val(),
+ date_publish: datePublish,
+ };
+ if (this.page.hasSingleGroup && this.$('#visibility').val() === 'restricted_group') {
+ params['group_id'] = this.$('#group_id').data('group-id');
+ }
+ if (this.$('#visibility').val() === 'password') {
+ var field_pwd = $('#visibility_password');
+ if (!field_pwd.get(0).reportValidity()) {
+ return;
+ }
+ if (field_pwd.data('dirty')) {
+ params['visibility_pwd'] = field_pwd.val();
+ }
+ }
+
+ this._rpc({
+ model: 'website.page',
+ method: 'save_page_info',
+ args: [[context.website_id], params],
+ }).then(function (url) {
+ // If from page manager: reload url, if from page itself: go to
+ // (possibly) new url
+ var mo;
+ self.trigger_up('main_object_request', {
+ callback: function (value) {
+ mo = value;
+ },
+ });
+ if (mo.model === 'website.page') {
+ window.location.href = url.toLowerCase();
+ } else {
+ window.location.reload(true);
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Retrieves the page URL dependencies for the given object id.
+ *
+ * @private
+ * @param {integer} moID
+ * @returns {Promise<Array>}
+ */
+ _getPageDependencies: function (moID) {
+ return this._rpc({
+ model: 'website',
+ method: 'page_search_dependencies',
+ args: [moID],
+ });
+ },
+ /**
+ * Retrieves the page's key dependencies for the given object id.
+ *
+ * @private
+ * @param {integer} moID
+ * @returns {Promise<Array>}
+ */
+ _getPageKeyDependencies: function (moID) {
+ return this._rpc({
+ model: 'website',
+ method: 'page_search_key_dependencies',
+ args: [moID],
+ });
+ },
+ /**
+ * Retrieves supported mimtype
+ *
+ * @private
+ * @returns {Promise<Array>}
+ */
+ _getSupportedMimetype: function () {
+ return this._rpc({
+ model: 'website',
+ method: 'guess_mimetype',
+ });
+ },
+ /**
+ * Returns information about the page main object.
+ *
+ * @private
+ * @returns {Object} model and id
+ */
+ _getMainObject: function () {
+ var repr = $('html').data('main-object');
+ var m = repr.match(/(.+)\((\d+),(.*)\)/);
+ return {
+ model: m[1],
+ id: m[2] | 0,
+ };
+ },
+ /**
+ * Converts a string representing the browser datetime
+ * (exemple: Albanian: '2018-Qer-22 15.12.35.')
+ * to a string representing UTC in Odoo's datetime string format
+ * (exemple: '2018-04-22 13:12:35').
+ *
+ * The time zone of the datetime string is assumed to be the one of the
+ * browser and it will be converted to UTC (standard for Odoo).
+ *
+ * @private
+ * @param {String} value A string representing a datetime.
+ * @returns {String|false} A string representing an UTC datetime if the given value is valid, false otherwise.
+ */
+ _parse_date: function (value) {
+ var datetime = moment(value, time.getLangDatetimeFormat(), true);
+ if (datetime.isValid()) {
+ return time.datetime_to_str(datetime.toDate());
+ }
+ else {
+ return false;
+ }
+ },
+ /**
+ * Allows the given input to propose existing groups.
+ *
+ * @param {jQuery} $input
+ */
+ autocompleteWithGroups: function ($input) {
+ $input.autocomplete({
+ source: (request, response) => {
+ return this._rpc({
+ model: 'res.groups',
+ method: 'search_read',
+ args: [[['name', 'ilike', request.term]], ['display_name']],
+ kwargs: {
+ limit: 15,
+ },
+ }).then(founds => {
+ founds = founds.map(g => ({'id': g['id'], 'label': g['display_name']}));
+ response(founds);
+ });
+ },
+ change: (ev, ui) => {
+ var $target = $(ev.target);
+ if (!ui.item) {
+ $target.val("");
+ $target.removeData('group-id');
+ } else {
+ $target.data('group-id', ui.item.id);
+ }
+ },
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onUrlChanged: function () {
+ var url = this.$('input#page_url').val();
+ this.$('.ask_for_redirect').toggleClass('d-none', url === this.page.url);
+ },
+ /**
+ * @private
+ */
+ _onNameChanged: function () {
+ var name = this.$('input#page_name').val();
+ // If the file type is a supported mimetype, check if it is t-called.
+ // If so, warn user. Note: different from page_search_dependencies which
+ // check only for url and not key
+ var ext = '.' + this.page.name.split('.').pop();
+ if (ext in this.supportedMimetype && ext !== '.html') {
+ this.$('.warn_about_call').toggleClass('d-none', name === this.page.name);
+ }
+ },
+ /**
+ * @private
+ */
+ _onCreateRedirectChanged: function () {
+ var createRedirect = this.$('input#create_redirect').prop('checked');
+ this.$('.redirect_type').toggleClass('d-none', !createRedirect);
+ },
+ /**
+ * @private
+ */
+ _onVisibilityChanged: function (ev) {
+ this.$('.show_visibility_password').toggleClass('d-none', ev.target.value !== 'password');
+ this.$('.show_group_id').toggleClass('d-none', ev.target.value !== 'restricted_group');
+ this.$('#visibility_password').attr('required', ev.target.value === 'password');
+ },
+ /**
+ * Library clears the wrong date format so just ignore error
+ *
+ * @private
+ */
+ _onDateTimePickerError: function (ev) {
+ return false;
+ },
+ /**
+ * @private
+ */
+ _onPasswordClicked: function (ev) {
+ ev.target.value = '';
+ this._onPasswordChanged();
+ },
+ /**
+ * @private
+ */
+ _onPasswordChanged: function () {
+ this.$('#visibility_password').data('dirty', 1);
+ },
+});
+
+var MenuEntryDialog = weWidgets.LinkDialog.extend({
+ xmlDependencies: weWidgets.LinkDialog.prototype.xmlDependencies.concat(
+ ['/website/static/src/xml/website.contentMenu.xml']
+ ),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options, editable, data) {
+ this._super(parent, _.extend({
+ title: _t("Add a menu item"),
+ }, options || {}), editable, _.extend({
+ needLabel: true,
+ text: data.name || '',
+ isNewWindow: data.new_window,
+ }, data || {}));
+
+ this.menuType = data.menuType;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ // Remove style related elements
+ this.$('.o_link_dialog_preview').remove();
+ this.$('input[name="is_new_window"], .link-style').closest('.form-group').remove();
+ this.$modal.find('.modal-lg').removeClass('modal-lg');
+ this.$('form.col-lg-8').removeClass('col-lg-8').addClass('col-12');
+
+ // Adapt URL label
+ this.$('label[for="o_link_dialog_label_input"]').text(_t("Menu Label"));
+
+ // Auto add '#' URL and hide the input if for mega menu
+ if (this.menuType === 'mega') {
+ var $url = this.$('input[name="url"]');
+ $url.val('#').trigger('change');
+ $url.closest('.form-group').addClass('d-none');
+ }
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ var $e = this.$('#o_link_dialog_label_input');
+ if (!$e.val() || !$e[0].checkValidity()) {
+ $e.closest('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ $e.focus();
+ return;
+ }
+ return this._super.apply(this, arguments);
+ },
+});
+
+var SelectEditMenuDialog = weWidgets.Dialog.extend({
+ template: 'website.contentMenu.dialog.select',
+ xmlDependencies: weWidgets.Dialog.prototype.xmlDependencies.concat(
+ ['/website/static/src/xml/website.contentMenu.xml']
+ ),
+
+ /**
+ * @constructor
+ * @override
+ */
+ init: function (parent, options) {
+ var self = this;
+ self.roots = [{id: null, name: _t("Top Menu")}];
+ $('[data-content_menu_id]').each(function () {
+ // Remove name fallback in master
+ self.roots.push({id: $(this).data('content_menu_id'), name: $(this).attr('name') || $(this).data('menu_name')});
+ });
+ this._super(parent, _.extend({}, {
+ title: _t("Select a Menu"),
+ save_text: _t("Continue")
+ }, options || {}));
+ },
+ /**
+ * @override
+ */
+ save: function () {
+ this.final_data = parseInt(this.$el.find('select').val() || null);
+ this._super.apply(this, arguments);
+ },
+});
+
+var EditMenuDialog = weWidgets.Dialog.extend({
+ template: 'website.contentMenu.dialog.edit',
+ xmlDependencies: weWidgets.Dialog.prototype.xmlDependencies.concat(
+ ['/website/static/src/xml/website.contentMenu.xml']
+ ),
+ events: _.extend({}, weWidgets.Dialog.prototype.events, {
+ 'click a.js_add_menu': '_onAddMenuButtonClick',
+ 'click button.js_delete_menu': '_onDeleteMenuButtonClick',
+ 'click button.js_edit_menu': '_onEditMenuButtonClick',
+ }),
+
+ /**
+ * @constructor
+ * @override
+ */
+ init: function (parent, options, rootID) {
+ this._super(parent, _.extend({}, {
+ title: _t("Edit Menu"),
+ size: 'medium',
+ }, options || {}));
+ this.rootID = rootID;
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ var defs = [this._super.apply(this, arguments)];
+ var context;
+ this.trigger_up('context_get', {
+ callback: function (ctx) {
+ context = ctx;
+ },
+ });
+ defs.push(this._rpc({
+ model: 'website.menu',
+ method: 'get_tree',
+ args: [context.website_id, this.rootID],
+ }).then(menu => {
+ this.menu = menu;
+ this.rootMenuID = menu.fields['id'];
+ this.flat = this._flatenize(menu);
+ this.toDelete = [];
+ }));
+ return Promise.all(defs);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var r = this._super.apply(this, arguments);
+ this.$('.oe_menu_editor').nestedSortable({
+ listType: 'ul',
+ handle: 'div',
+ items: 'li',
+ maxLevels: 2,
+ toleranceElement: '> div',
+ forcePlaceholderSize: true,
+ opacity: 0.6,
+ placeholder: 'oe_menu_placeholder',
+ tolerance: 'pointer',
+ attribute: 'data-menu-id',
+ expression: '()(.+)', // nestedSortable takes the second match of an expression (*sigh*)
+ isAllowed: (placeholder, placeholderParent, currentItem) => {
+ return !placeholderParent
+ || !currentItem[0].dataset.megaMenu && !placeholderParent[0].dataset.megaMenu;
+ },
+ });
+ return r;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ var _super = this._super.bind(this);
+ var newMenus = this.$('.oe_menu_editor').nestedSortable('toArray', {startDepthCount: 0});
+ var levels = [];
+ var data = [];
+ var context;
+ this.trigger_up('context_get', {
+ callback: function (ctx) {
+ context = ctx;
+ },
+ });
+ // Resequence, re-tree and remove useless data
+ newMenus.forEach(menu => {
+ if (menu.id) {
+ levels[menu.depth] = (levels[menu.depth] || 0) + 1;
+ var menuFields = this.flat[menu.id].fields;
+ menuFields['sequence'] = levels[menu.depth];
+ menuFields['parent_id'] = menu['parent_id'] || this.rootMenuID;
+ data.push(menuFields);
+ }
+ });
+ return this._rpc({
+ model: 'website.menu',
+ method: 'save',
+ args: [
+ context.website_id,
+ {
+ 'data': data,
+ 'to_delete': this.toDelete,
+ }
+ ],
+ }).then(function () {
+ return _super();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns a mapping id -> menu item containing all the menu items in the
+ * given menu hierarchy.
+ *
+ * @private
+ * @param {Object} node
+ * @param {Object} [_dict] internal use: the mapping being built
+ * @returns {Object}
+ */
+ _flatenize: function (node, _dict) {
+ _dict = _dict || {};
+ _dict[node.fields['id']] = node;
+ node.children.forEach(child => {
+ this._flatenize(child, _dict);
+ });
+ return _dict;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the "add menu" button is clicked -> Opens the appropriate
+ * dialog to edit this new menu.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onAddMenuButtonClick: function (ev) {
+ var menuType = ev.currentTarget.dataset.type;
+ var dialog = new MenuEntryDialog(this, {}, null, {
+ menuType: menuType,
+ });
+ dialog.on('save', this, link => {
+ var newMenu = {
+ 'fields': {
+ 'id': _.uniqueId('new-'),
+ 'name': _.unescape(link.text),
+ 'url': link.url,
+ 'new_window': link.isNewWindow,
+ 'is_mega_menu': menuType === 'mega',
+ 'sequence': 0,
+ 'parent_id': false,
+ },
+ 'children': [],
+ 'is_homepage': false,
+ };
+ this.flat[newMenu.fields['id']] = newMenu;
+ this.$('.oe_menu_editor').append(
+ qweb.render('website.contentMenu.dialog.submenu', {submenu: newMenu})
+ );
+ });
+ dialog.open();
+ },
+ /**
+ * Called when the "delete menu" button is clicked -> Deletes this menu.
+ *
+ * @private
+ */
+ _onDeleteMenuButtonClick: function (ev) {
+ var $menu = $(ev.currentTarget).closest('[data-menu-id]');
+ var menuID = parseInt($menu.data('menu-id'));
+ if (menuID) {
+ this.toDelete.push(menuID);
+ }
+ $menu.remove();
+ },
+ /**
+ * Called when the "edit menu" button is clicked -> Opens the appropriate
+ * dialog to edit this menu.
+ *
+ * @private
+ */
+ _onEditMenuButtonClick: function (ev) {
+ var $menu = $(ev.currentTarget).closest('[data-menu-id]');
+ var menuID = $menu.data('menu-id');
+ var menu = this.flat[menuID];
+ if (menu) {
+ var dialog = new MenuEntryDialog(this, {}, null, _.extend({
+ menuType: menu.fields['is_mega_menu'] ? 'mega' : undefined,
+ }, menu.fields));
+ dialog.on('save', this, link => {
+ _.extend(menu.fields, {
+ 'name': _.unescape(link.text),
+ 'url': link.url,
+ 'new_window': link.isNewWindow,
+ });
+ $menu.find('.js_menu_label').first().text(menu.fields['name']);
+ });
+ dialog.open();
+ } else {
+ Dialog.alert(null, "Could not find menu entry");
+ }
+ },
+});
+
+var PageOption = Class.extend({
+ /**
+ * @constructor
+ * @param {string} name
+ * the option's name = the field's name in website.page model
+ * @param {*} value
+ * @param {function} setValueCallback
+ * a function which simulates an option's value change without
+ * asking the server to change it
+ */
+ init: function (name, value, setValueCallback) {
+ this.name = name;
+ this.value = value;
+ this.isDirty = false;
+ this.setValueCallback = setValueCallback;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the new option's value thanks to the related callback.
+ *
+ * @param {*} [value]
+ * by default: consider the current value is a boolean and toggle it
+ */
+ setValue: function (value) {
+ if (value === undefined) {
+ value = !this.value;
+ }
+ this.setValueCallback.call(this, value);
+ this.value = value;
+ this.isDirty = true;
+ },
+});
+
+var ContentMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
+ xmlDependencies: ['/website/static/src/xml/website.xml'],
+ actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, {
+ edit_menu: '_editMenu',
+ get_page_option: '_getPageOption',
+ on_save: '_onSave',
+ page_properties: '_pageProperties',
+ toggle_page_option: '_togglePageOption',
+ }),
+ pageOptionsSetValueCallbacks: {
+ header_overlay: function (value) {
+ $('#wrapwrap').toggleClass('o_header_overlay', value);
+ },
+ header_color: function (value) {
+ $('#wrapwrap > header').removeClass(this.value)
+ .addClass(value);
+ },
+ header_visible: function (value) {
+ $('#wrapwrap > header').toggleClass('d-none o_snippet_invisible', !value);
+ },
+ footer_visible: function (value) {
+ $('#wrapwrap > footer').toggleClass('d-none o_snippet_invisible', !value);
+ },
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.pageOptions = {};
+ _.each($('.o_page_option_data'), function (el) {
+ var value = el.value;
+ if (value === "True") {
+ value = true;
+ } else if (value === "False") {
+ value = false;
+ }
+ self.pageOptions[el.name] = new PageOption(
+ el.name,
+ value,
+ self.pageOptionsSetValueCallbacks[el.name]
+ );
+ });
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Actions
+ //--------------------------------------------------------------------------
+
+ /**
+ * Asks the user which menu to edit if multiple menus exist on the page.
+ * Then opens the menu edition dialog.
+ * Then executes the given callback once the edition is saved, to finally
+ * reload the page.
+ *
+ * @private
+ * @param {function} [beforeReloadCallback]
+ * @returns {Promise}
+ * Unresolved if the menu is edited and saved as the page will be
+ * reloaded.
+ * Resolved otherwise.
+ */
+ _editMenu: function (beforeReloadCallback) {
+ var self = this;
+ return new Promise(function (resolve) {
+ function resolveWhenEditMenuDialogIsCancelled(rootID) {
+ return self._openEditMenuDialog(rootID, beforeReloadCallback).then(resolve);
+ }
+ if ($('[data-content_menu_id]').length) {
+ var select = new SelectEditMenuDialog(self);
+ select.on('save', self, resolveWhenEditMenuDialogIsCancelled);
+ select.on('cancel', self, resolve);
+ select.open();
+ } else {
+ resolveWhenEditMenuDialogIsCancelled(null);
+ }
+ });
+ },
+ /**
+ *
+ * @param {*} rootID
+ * @param {function|undefied} beforeReloadCallback function that returns a promise
+ * @returns {Promise}
+ */
+ _openEditMenuDialog: function (rootID, beforeReloadCallback) {
+ var self = this;
+ return new Promise(function (resolve) {
+ var dialog = new EditMenuDialog(self, {}, rootID);
+ dialog.on('save', self, function () {
+ // Before reloading the page after menu modification, does the
+ // given action to do.
+ if (beforeReloadCallback) {
+ // Reload the page so that the menu modification are shown
+ beforeReloadCallback().then(function () {
+ window.location.reload(true);
+ });
+ } else {
+ window.location.reload(true);
+ }
+ });
+ dialog.on('cancel', self, resolve);
+ dialog.open();
+ });
+ },
+
+ /**
+ * Retrieves the value of a page option.
+ *
+ * @private
+ * @param {string} name
+ * @returns {Promise<*>}
+ */
+ _getPageOption: function (name) {
+ var option = this.pageOptions[name];
+ if (!option) {
+ return Promise.reject();
+ }
+ return Promise.resolve(option.value);
+ },
+ /**
+ * On save, simulated page options have to be server-saved.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _onSave: function () {
+ var self = this;
+ var defs = _.map(this.pageOptions, function (option, optionName) {
+ if (option.isDirty) {
+ return self._togglePageOption({
+ name: optionName,
+ value: option.value,
+ }, true, true);
+ }
+ });
+ return Promise.all(defs);
+ },
+ /**
+ * Opens the page properties dialog.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _pageProperties: function () {
+ var mo;
+ this.trigger_up('main_object_request', {
+ callback: function (value) {
+ mo = value;
+ },
+ });
+ var dialog = new PagePropertiesDialog(this, mo.id, {}).open();
+ return dialog.opened();
+ },
+ /**
+ * Toggles a page option.
+ *
+ * @private
+ * @param {Object} params
+ * @param {string} params.name
+ * @param {*} [params.value] (change value by default true -> false -> true)
+ * @param {boolean} [forceSave=false]
+ * @param {boolean} [noReload=false]
+ * @returns {Promise}
+ */
+ _togglePageOption: function (params, forceSave, noReload) {
+ // First check it is a website page
+ var mo;
+ this.trigger_up('main_object_request', {
+ callback: function (value) {
+ mo = value;
+ },
+ });
+ if (mo.model !== 'website.page') {
+ return Promise.reject();
+ }
+
+ // Check if this is a valid option
+ var option = this.pageOptions[params.name];
+ if (!option) {
+ return Promise.reject();
+ }
+
+ // Toggle the value
+ option.setValue(params.value);
+
+ // If simulate is true, it means we want the option to be toggled but
+ // not saved on the server yet
+ if (!forceSave) {
+ return Promise.resolve();
+ }
+
+ // If not, write on the server page and reload the current location
+ var vals = {};
+ vals[params.name] = option.value;
+ var prom = this._rpc({
+ model: 'website.page',
+ method: 'write',
+ args: [[mo.id], vals],
+ });
+ if (noReload) {
+ return prom;
+ }
+ return prom.then(function () {
+ window.location.reload();
+ return new Promise(function () {});
+ });
+ },
+});
+
+var PageManagement = Widget.extend({
+ xmlDependencies: ['/website/static/src/xml/website.xml'],
+ events: {
+ 'click a.js_page_properties': '_onPagePropertiesButtonClick',
+ 'click a.js_clone_page': '_onClonePageButtonClick',
+ 'click a.js_delete_page': '_onDeletePageButtonClick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Retrieves the page dependencies for the given object id.
+ *
+ * @private
+ * @param {integer} moID
+ * @returns {Promise<Array>}
+ */
+ _getPageDependencies: function (moID) {
+ return this._rpc({
+ model: 'website',
+ method: 'page_search_dependencies',
+ args: [moID],
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ _onPagePropertiesButtonClick: function (ev) {
+ var moID = $(ev.currentTarget).data('id');
+ var dialog = new PagePropertiesDialog(this,moID, {'fromPageManagement': true}).open();
+ return dialog;
+ },
+ _onClonePageButtonClick: function (ev) {
+ var pageId = $(ev.currentTarget).data('id');
+ _clonePage.call(this, pageId);
+ },
+ _onDeletePageButtonClick: function (ev) {
+ var pageId = $(ev.currentTarget).data('id');
+ _deletePage.call(this, pageId, true);
+ },
+});
+
+/**
+ * Deletes the page after showing a dependencies warning for the given page id.
+ *
+ * @private
+ * @param {integer} pageId - The ID of the page to be deleted
+ * @param {Boolean} fromPageManagement
+ * Is the function called by the page manager?
+ * It will affect redirect after page deletion: reload or '/'
+ */
+// TODO: This function should be integrated in a widget in the future
+function _deletePage(pageId, fromPageManagement) {
+ var self = this;
+ new Promise(function (resolve, reject) {
+ // Search the page dependencies
+ self._getPageDependencies(pageId)
+ .then(function (dependencies) {
+ // Inform the user about those dependencies and ask him confirmation
+ return new Promise(function (confirmResolve, confirmReject) {
+ Dialog.safeConfirm(self, "", {
+ title: _t("Delete Page"),
+ $content: $(qweb.render('website.delete_page', {dependencies: dependencies})),
+ confirm_callback: confirmResolve,
+ cancel_callback: resolve,
+ });
+ });
+ }).then(function () {
+ // Delete the page if the user confirmed
+ return self._rpc({
+ model: 'website.page',
+ method: 'unlink',
+ args: [pageId],
+ });
+ }).then(function () {
+ if (fromPageManagement) {
+ window.location.reload(true);
+ } else {
+ window.location.href = '/';
+ }
+ }, reject);
+ });
+}
+/**
+ * Duplicate the page after showing the wizard to enter new page name.
+ *
+ * @private
+ * @param {integer} pageId - The ID of the page to be duplicate
+ *
+ */
+function _clonePage(pageId) {
+ var self = this;
+ new Promise(function (resolve, reject) {
+ Dialog.confirm(this, undefined, {
+ title: _t("Duplicate Page"),
+ $content: $(qweb.render('website.duplicate_page_action_dialog')),
+ confirm_callback: function () {
+ var new_page_name = this.$('#page_name').val();
+ return self._rpc({
+ model: 'website.page',
+ method: 'clone_page',
+ args: [pageId, new_page_name],
+ }).then(function (path) {
+ window.location.href = path;
+ }).guardedCatch(reject);
+ },
+ cancel_callback: reject,
+ }).on('closed', null, reject);
+ });
+}
+
+websiteNavbarData.websiteNavbarRegistry.add(ContentMenu, '#content-menu');
+websiteRootData.websiteRootRegistry.add(PageManagement, '#list_website_pages');
+
+return {
+ PagePropertiesDialog: PagePropertiesDialog,
+ ContentMenu: ContentMenu,
+ EditMenuDialog: EditMenuDialog,
+ MenuEntryDialog: MenuEntryDialog,
+ SelectEditMenuDialog: SelectEditMenuDialog,
+};
+});
diff --git a/addons/website/static/src/js/menu/customize.js b/addons/website/static/src/js/menu/customize.js
new file mode 100644
index 00000000..4481d0f6
--- /dev/null
+++ b/addons/website/static/src/js/menu/customize.js
@@ -0,0 +1,219 @@
+odoo.define('website.customizeMenu', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Widget = require('web.Widget');
+var websiteNavbarData = require('website.navbar');
+var WebsiteAceEditor = require('website.ace');
+
+var qweb = core.qweb;
+
+var CustomizeMenu = Widget.extend({
+ xmlDependencies: ['/website/static/src/xml/website.editor.xml'],
+ events: {
+ 'show.bs.dropdown': '_onDropdownShow',
+ 'click .dropdown-item[data-view-key]': '_onCustomizeOptionClick',
+ },
+
+ /**
+ * @override
+ */
+ willStart: function () {
+ this.viewName = $(document.documentElement).data('view-xmlid');
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ if (!this.viewName) {
+ _.defer(this.destroy.bind(this));
+ }
+
+ if (this.$el.is('.show')) {
+ this._loadCustomizeOptions();
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Enables/Disables a view customization whose id is given.
+ *
+ * @private
+ * @param {string} viewKey
+ * @returns {Promise}
+ * Unresolved if the customization succeeded as the page will be
+ * reloaded.
+ * Rejected otherwise.
+ */
+ _doCustomize: function (viewKey) {
+ return this._rpc({
+ route: '/website/toggle_switchable_view',
+ params: {
+ 'view_key': viewKey,
+ },
+ }).then(function () {
+ window.location.reload();
+ return new Promise(function () {});
+ });
+ },
+ /**
+ * Loads the information about the views which can be enabled/disabled on
+ * the current page and shows them as switchable elements in the menu.
+ *
+ * @private
+ * @return {Promise}
+ */
+ _loadCustomizeOptions: function () {
+ if (this.__customizeOptionsLoaded) {
+ return Promise.resolve();
+ }
+ this.__customizeOptionsLoaded = true;
+
+ var $menu = this.$el.children('.dropdown-menu');
+ return this._rpc({
+ route: '/website/get_switchable_related_views',
+ params: {
+ key: this.viewName,
+ },
+ }).then(function (result) {
+ var currentGroup = '';
+ if (result.length) {
+ $menu.append($('<div/>', {
+ class: 'dropdown-divider',
+ role: 'separator',
+ }));
+ }
+ _.each(result, function (item) {
+ if (currentGroup !== item.inherit_id[1]) {
+ currentGroup = item.inherit_id[1];
+ $menu.append('<li class="dropdown-header">' + currentGroup + '</li>');
+ }
+ var $a = $('<a/>', {href: '#', class: 'dropdown-item', 'data-view-key': item.key, role: 'menuitem'})
+ .append(qweb.render('website.components.switch', {id: 'switch-' + item.id, label: item.name}));
+ $a.find('input').prop('checked', !!item.active);
+ $menu.append($a);
+ });
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a view's related switchable element is clicked -> enable /
+ * disable the related view.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onCustomizeOptionClick: function (ev) {
+ ev.preventDefault();
+ var viewKey = $(ev.currentTarget).data('viewKey');
+ this._doCustomize(viewKey);
+ },
+ /**
+ * @private
+ */
+ _onDropdownShow: function () {
+ this._loadCustomizeOptions();
+ },
+});
+
+var AceEditorMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
+ actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, {
+ close_all_widgets: '_hideEditor',
+ edit: '_enterEditMode',
+ ace: '_launchAce',
+ }),
+
+ /**
+ * Launches the ace editor automatically when the corresponding hash is in
+ * the page URL.
+ *
+ * @override
+ */
+ start: function () {
+ if (window.location.hash.substr(0, WebsiteAceEditor.prototype.hash.length) === WebsiteAceEditor.prototype.hash) {
+ this._launchAce();
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Actions
+ //--------------------------------------------------------------------------
+
+ /**
+ * When handling the "edit" website action, the ace editor has to be closed.
+ *
+ * @private
+ */
+ _enterEditMode: function () {
+ this._hideEditor();
+ },
+ /**
+ * @private
+ */
+ _hideEditor: function () {
+ if (this.globalEditor) {
+ this.globalEditor.do_hide();
+ }
+ },
+ /**
+ * Launches the ace editor to be able to edit the templates and scss files
+ * which are used by the current page.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _launchAce: function () {
+ var self = this;
+ var prom = new Promise(function (resolve, reject) {
+ self.trigger_up('action_demand', {
+ actionName: 'close_all_widgets',
+ onSuccess: resolve,
+ });
+ });
+ prom.then(function () {
+ if (self.globalEditor) {
+ self.globalEditor.do_show();
+ return Promise.resolve();
+ } else {
+ var currentHash = window.location.hash;
+ var indexOfView = currentHash.indexOf("?res=");
+ var initialResID = undefined;
+ if (indexOfView >= 0) {
+ initialResID = currentHash.substr(indexOfView + ("?res=".length));
+ var parsedResID = parseInt(initialResID, 10);
+ if (parsedResID) {
+ initialResID = parsedResID;
+ }
+ }
+
+ self.globalEditor = new WebsiteAceEditor(self, $(document.documentElement).data('view-xmlid'), {
+ initialResID: initialResID,
+ defaultBundlesRestriction: [
+ 'web.assets_frontend',
+ 'web.assets_frontend_minimal',
+ 'web.assets_frontend_lazy',
+ ],
+ });
+ return self.globalEditor.appendTo(document.body);
+ }
+ });
+
+ return prom;
+ },
+});
+
+websiteNavbarData.websiteNavbarRegistry.add(CustomizeMenu, '#customize-menu');
+websiteNavbarData.websiteNavbarRegistry.add(AceEditorMenu, '#html_editor');
+
+return CustomizeMenu;
+});
diff --git a/addons/website/static/src/js/menu/debug_manager.js b/addons/website/static/src/js/menu/debug_manager.js
new file mode 100644
index 00000000..e932daa7
--- /dev/null
+++ b/addons/website/static/src/js/menu/debug_manager.js
@@ -0,0 +1,21 @@
+odoo.define('website.debugManager', function (require) {
+'use strict';
+
+var config = require('web.config');
+var DebugManager = require('web.DebugManager');
+var websiteNavbarData = require('website.navbar');
+
+var DebugManagerMenu = websiteNavbarData.WebsiteNavbar.include({
+ /**
+ * @override
+ */
+ start: function () {
+ if (config.isDebug()) {
+ new DebugManager(this).prependTo(this.$('.o_menu_systray'));
+ }
+ return this._super.apply(this, arguments);
+ },
+});
+
+return DebugManagerMenu;
+});
diff --git a/addons/website/static/src/js/menu/edit.js b/addons/website/static/src/js/menu/edit.js
new file mode 100644
index 00000000..d448b15d
--- /dev/null
+++ b/addons/website/static/src/js/menu/edit.js
@@ -0,0 +1,256 @@
+odoo.define('website.editMenu', function (require) {
+'use strict';
+
+var core = require('web.core');
+var EditorMenu = require('website.editor.menu');
+var websiteNavbarData = require('website.navbar');
+
+var _t = core._t;
+
+/**
+ * Adds the behavior when clicking on the 'edit' button (+ editor interaction)
+ */
+var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
+ assetLibs: ['web_editor.compiled_assets_wysiwyg', 'website.compiled_assets_wysiwyg'],
+
+ xmlDependencies: ['/website/static/src/xml/website.editor.xml'],
+ actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions, {
+ edit: '_startEditMode',
+ on_save: '_onSave',
+ }),
+ custom_events: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.custom_events || {}, {
+ content_will_be_destroyed: '_onContentWillBeDestroyed',
+ content_was_recreated: '_onContentWasRecreated',
+ snippet_will_be_cloned: '_onSnippetWillBeCloned',
+ snippet_cloned: '_onSnippetCloned',
+ snippet_dropped: '_onSnippetDropped',
+ edition_will_stopped: '_onEditionWillStop',
+ edition_was_stopped: '_onEditionWasStopped',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ var context;
+ this.trigger_up('context_get', {
+ extra: true,
+ callback: function (ctx) {
+ context = ctx;
+ },
+ });
+ this._editorAutoStart = (context.editable && window.location.search.indexOf('enable_editor') >= 0);
+ var url = window.location.href.replace(/([?&])&*enable_editor[^&#]*&?/, '\$1');
+ window.history.replaceState({}, null, url);
+ },
+ /**
+ * Auto-starts the editor if necessary or add the welcome message otherwise.
+ *
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+
+ // If we auto start the editor, do not show a welcome message
+ if (this._editorAutoStart) {
+ return Promise.all([def, this._startEditMode()]);
+ }
+
+ // Check that the page is empty
+ var $wrap = this._targetForEdition().filter('#wrapwrap.homepage').find('#wrap');
+
+ if ($wrap.length && $wrap.html().trim() === '') {
+ // If readonly empty page, show the welcome message
+ this.$welcomeMessage = $(core.qweb.render('website.homepage_editor_welcome_message'));
+ this.$welcomeMessage.addClass('o_homepage_editor_welcome_message');
+ this.$welcomeMessage.css('min-height', $wrap.parent('main').height() - ($wrap.outerHeight(true) - $wrap.height()));
+ $wrap.empty().append(this.$welcomeMessage);
+ }
+
+ return def;
+ },
+
+ //--------------------------------------------------------------------------
+ // Actions
+ //--------------------------------------------------------------------------
+
+ /**
+ * Creates an editor instance and appends it to the DOM. Also remove the
+ * welcome message if necessary.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _startEditMode: async function () {
+ var self = this;
+ if (this.editModeEnable) {
+ return;
+ }
+ this.trigger_up('widgets_stop_request', {
+ $target: this._targetForEdition(),
+ });
+ if (this.$welcomeMessage) {
+ this.$welcomeMessage.detach(); // detach from the readonly rendering before the clone by summernote
+ }
+ this.editModeEnable = true;
+ await new EditorMenu(this).prependTo(document.body);
+ this._addEditorMessages();
+ var res = await new Promise(function (resolve, reject) {
+ self.trigger_up('widgets_start_request', {
+ editableMode: true,
+ onSuccess: resolve,
+ onFailure: reject,
+ });
+ });
+ // Trigger a mousedown on the main edition area to focus it,
+ // which is required for Summernote to activate.
+ this.$editorMessageElements.mousedown();
+ return res;
+ },
+ /**
+ * On save, the editor will ask to parent widgets if something needs to be
+ * done first. The website navbar will receive that demand and asks to its
+ * action-capable components to do something. For example, the content menu
+ * handles page-related options saving. However, some users with limited
+ * access rights do not have the content menu... but the website navbar
+ * expects that the save action is performed. So, this empty action is
+ * defined here so that all users have an 'on_save' related action.
+ *
+ * @private
+ * @todo improve the system to somehow declare required/optional actions
+ */
+ _onSave: function () {},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds automatic editor messages on drag&drop zone elements.
+ *
+ * @private
+ */
+ _addEditorMessages: function () {
+ const $target = this._targetForEdition();
+ const $skeleton = $target.find('.oe_structure.oe_empty, [data-oe-type="html"]');
+ this.$editorMessageElements = $skeleton.not('[data-editor-message]').attr('data-editor-message', _t('DRAG BUILDING BLOCKS HERE'));
+ $skeleton.attr('contenteditable', function () { return !$(this).is(':empty'); });
+ },
+ /**
+ * Returns the target for edition.
+ *
+ * @private
+ * @returns {JQuery}
+ */
+ _targetForEdition: function () {
+ return $('#wrapwrap'); // TODO should know about this element another way
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when content will be destroyed in the page. Notifies the
+ * WebsiteRoot that is should stop the public widgets.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onContentWillBeDestroyed: function (ev) {
+ this.trigger_up('widgets_stop_request', {
+ $target: ev.data.$target,
+ });
+ },
+ /**
+ * Called when content was recreated in the page. Notifies the
+ * WebsiteRoot that is should start the public widgets.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onContentWasRecreated: function (ev) {
+ this.trigger_up('widgets_start_request', {
+ editableMode: true,
+ $target: ev.data.$target,
+ });
+ },
+ /**
+ * Called when edition will stop. Notifies the
+ * WebsiteRoot that is should stop the public widgets.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onEditionWillStop: function (ev) {
+ this.$editorMessageElements && this.$editorMessageElements.removeAttr('data-editor-message');
+ this.trigger_up('widgets_stop_request', {
+ $target: this._targetForEdition(),
+ });
+ },
+ /**
+ * Called when edition was stopped. Notifies the
+ * WebsiteRoot that is should start the public widgets.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onEditionWasStopped: function (ev) {
+ this.trigger_up('widgets_start_request', {
+ $target: this._targetForEdition(),
+ });
+ this.editModeEnable = false;
+ },
+ /**
+ * Called when a snippet is about to be cloned in the page. Notifies the
+ * WebsiteRoot that is should destroy the animations for this snippet.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetWillBeCloned: function (ev) {
+ this.trigger_up('widgets_stop_request', {
+ $target: ev.data.$target,
+ });
+ },
+ /**
+ * Called when a snippet is cloned in the page. Notifies the WebsiteRoot
+ * that is should start the public widgets for this snippet and the snippet it
+ * was cloned from.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetCloned: function (ev) {
+ this.trigger_up('widgets_start_request', {
+ editableMode: true,
+ $target: ev.data.$target,
+ });
+ // TODO: remove in saas-12.5, undefined $origin will restart #wrapwrap
+ if (ev.data.$origin) {
+ this.trigger_up('widgets_start_request', {
+ editableMode: true,
+ $target: ev.data.$origin,
+ });
+ }
+ },
+ /**
+ * Called when a snippet is dropped in the page. Notifies the WebsiteRoot
+ * that is should start the public widgets for this snippet. Also add the
+ * editor messages.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetDropped: function (ev) {
+ this.trigger_up('widgets_start_request', {
+ editableMode: true,
+ $target: ev.data.$target,
+ });
+ this._addEditorMessages();
+ },
+});
+
+websiteNavbarData.websiteNavbarRegistry.add(EditPageMenu, '#edit-page-menu');
+});
diff --git a/addons/website/static/src/js/menu/mobile_view.js b/addons/website/static/src/js/menu/mobile_view.js
new file mode 100644
index 00000000..668962c8
--- /dev/null
+++ b/addons/website/static/src/js/menu/mobile_view.js
@@ -0,0 +1,68 @@
+odoo.define('website.mobile', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var websiteNavbarData = require('website.navbar');
+
+var _t = core._t;
+
+var MobilePreviewDialog = Dialog.extend({
+ /**
+ * Tweaks the modal so that it appears as a phone and modifies the iframe
+ * rendering to show more accurate mobile view.
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.$modal.addClass('oe_mobile_preview');
+ this.$modal.on('click', '.modal-header', function () {
+ self.$el.toggleClass('o_invert_orientation');
+ });
+ this.$iframe = $('<iframe/>', {
+ id: 'mobile-viewport',
+ src: $.param.querystring(window.location.href, 'mobilepreview'),
+ });
+ this.$iframe.on('load', function (e) {
+ self.$iframe.contents().find('body').removeClass('o_connected_user');
+ self.$iframe.contents().find('#oe_main_menu_navbar').remove();
+ });
+ this.$iframe.appendTo(this.$el);
+
+ return this._super.apply(this, arguments);
+ },
+});
+
+var MobileMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
+ actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, {
+ 'show-mobile-preview': '_onMobilePreviewClick',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the mobile action is triggered -> instantiate the mobile
+ * preview dialog.
+ *
+ * @private
+ */
+ _onMobilePreviewClick: function () {
+ if (this.mobilePreview && !this.mobilePreview.isDestroyed()) {
+ return this.mobilePreview.close();
+ }
+ this.mobilePreview = new MobilePreviewDialog(this, {
+ title: _t('Mobile preview') + ' <span class="fa fa-refresh"/>',
+ }).open();
+ },
+});
+
+websiteNavbarData.websiteNavbarRegistry.add(MobileMenu, '#mobile-menu');
+
+return {
+ MobileMenu: MobileMenu,
+ MobilePreviewDialog: MobilePreviewDialog,
+};
+});
diff --git a/addons/website/static/src/js/menu/navbar.js b/addons/website/static/src/js/menu/navbar.js
new file mode 100644
index 00000000..937392f8
--- /dev/null
+++ b/addons/website/static/src/js/menu/navbar.js
@@ -0,0 +1,292 @@
+odoo.define('website.navbar', function (require) {
+'use strict';
+
+var core = require('web.core');
+var dom = require('web.dom');
+var publicWidget = require('web.public.widget');
+var concurrency = require('web.concurrency');
+var Widget = require('web.Widget');
+var websiteRootData = require('website.root');
+
+var websiteNavbarRegistry = new publicWidget.RootWidgetRegistry();
+
+var WebsiteNavbar = publicWidget.RootWidget.extend({
+ xmlDependencies: ['/website/static/src/xml/website.xml'],
+ events: _.extend({}, publicWidget.RootWidget.prototype.events || {}, {
+ 'click [data-action]': '_onActionMenuClick',
+ 'mouseover > ul > li.dropdown:not(.show)': '_onMenuHovered',
+ 'click .o_mobile_menu_toggle': '_onMobileMenuToggleClick',
+ 'mouseenter #oe_applications:not(:has(.dropdown-item))': '_onOeApplicationsHovered',
+ 'show.bs.dropdown #oe_applications:not(:has(.dropdown-item))': '_onOeApplicationsShow',
+ }),
+ custom_events: _.extend({}, publicWidget.RootWidget.prototype.custom_events || {}, {
+ 'action_demand': '_onActionDemand',
+ 'edit_mode': '_onEditMode',
+ 'readonly_mode': '_onReadonlyMode',
+ 'ready_to_save': '_onSave',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ var self = this;
+ var initPromise = new Promise(function (resolve) {
+ self.resolveInit = resolve;
+ });
+ this._widgetDefs = [initPromise];
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ dom.initAutoMoreMenu(this.$('ul.o_menu_sections'), {
+ maxWidth: function () {
+ // The navbar contains different elements in community and
+ // enterprise, so we check for both of them here only
+ return self.$el.width()
+ - (self.$('.o_menu_systray').outerWidth(true) || 0)
+ - (self.$('ul#oe_applications').outerWidth(true) || 0)
+ - (self.$('.o_menu_toggle').outerWidth(true) || 0)
+ - (self.$('.o_menu_brand').outerWidth(true) || 0);
+ },
+ });
+ return this._super.apply(this, arguments).then(function () {
+ self.resolveInit();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _attachComponent: function () {
+ var def = this._super.apply(this, arguments);
+ this._widgetDefs.push(def);
+ return def;
+ },
+ /**
+ * As the WebsiteNavbar instance is designed to be unique, the associated
+ * registry has been instantiated outside of the class and is simply
+ * returned here.
+ *
+ * @override
+ */
+ _getRegistry: function () {
+ return websiteNavbarRegistry;
+ },
+ /**
+ * Searches for the automatic widget {@see RootWidget} which can handle that
+ * action.
+ *
+ * @private
+ * @param {string} actionName
+ * @param {Array} params
+ * @returns {Promise}
+ */
+ _handleAction: function (actionName, params, _i) {
+ var self = this;
+ return this._whenReadyForActions().then(function () {
+ var defs = [];
+ _.each(self._widgets, function (w) {
+ if (!w.handleAction) {
+ return;
+ }
+
+ var def = w.handleAction(actionName, params);
+ if (def !== null) {
+ defs.push(def);
+ }
+ });
+ if (!defs.length) {
+ // Handle the case where all action-capable components are not
+ // instantiated yet (rare) -> retry some times to eventually abort
+ if (_i > 50) {
+ console.warn(_.str.sprintf("Action '%s' was not able to be handled.", actionName));
+ return Promise.reject();
+ }
+ return concurrency.delay(100).then(function () {
+ return self._handleAction(actionName, params, (_i || 0) + 1);
+ });
+ }
+ return Promise.all(defs).then(function (values) {
+ if (values.length === 1) {
+ return values[0];
+ }
+ return values;
+ });
+ });
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ async _loadAppMenus() {
+ if (!this._loadAppMenusProm) {
+ this._loadAppMenusProm = this._rpc({
+ model: 'ir.ui.menu',
+ method: 'load_menus_root',
+ args: [],
+ });
+ const result = await this._loadAppMenusProm;
+ const menus = core.qweb.render('website.oe_applications_menu', {
+ 'menu_data': result,
+ });
+ this.$('#oe_applications .dropdown-menu').html(menus);
+ }
+ return this._loadAppMenusProm;
+ },
+ /**
+ * @private
+ */
+ _whenReadyForActions: function () {
+ return Promise.all(this._widgetDefs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the backend applications menu is hovered -> fetch the
+ * available menus and insert it in DOM.
+ *
+ * @private
+ */
+ _onOeApplicationsHovered: function () {
+ this._loadAppMenus();
+ },
+ /**
+ * Called when the backend applications menu is opening -> fetch the
+ * available menus and insert it in DOM. Needed on top of hovering as the
+ * dropdown could be opened via keyboard (or the user could just already
+ * be over the dropdown when the JS is fully loaded).
+ *
+ * @private
+ */
+ _onOeApplicationsShow: function () {
+ this._loadAppMenus();
+ },
+ /**
+ * Called when an action menu is clicked -> searches for the automatic
+ * widget {@see RootWidget} which can handle that action.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onActionMenuClick: function (ev) {
+ const restore = dom.addButtonLoadingEffect(ev.currentTarget);
+ this._handleAction($(ev.currentTarget).data('action')).then(restore).guardedCatch(restore);
+ },
+ /**
+ * Called when an action is asked to be executed from a child widget ->
+ * searches for the automatic widget {@see RootWidget} which can handle
+ * that action.
+ */
+ _onActionDemand: function (ev) {
+ var def = this._handleAction(ev.data.actionName, ev.data.params);
+ if (ev.data.onSuccess) {
+ def.then(ev.data.onSuccess);
+ }
+ if (ev.data.onFailure) {
+ def.guardedCatch(ev.data.onFailure);
+ }
+ },
+ /**
+ * Called in response to edit mode activation -> hides the navbar.
+ *
+ * @private
+ */
+ _onEditMode: function () {
+ this.$el.addClass('editing_mode');
+ this.do_hide();
+ },
+ /**
+ * Called when a submenu is hovered -> automatically opens it if another
+ * menu was already opened.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMenuHovered: function (ev) {
+ var $opened = this.$('> ul > li.dropdown.show');
+ if ($opened.length) {
+ $opened.find('.dropdown-toggle').dropdown('toggle');
+ $(ev.currentTarget).find('.dropdown-toggle').dropdown('toggle');
+ }
+ },
+ /**
+ * Called when the mobile menu toggle button is click -> modifies the DOM
+ * to open the mobile menu.
+ *
+ * @private
+ */
+ _onMobileMenuToggleClick: function () {
+ this.$el.parent().toggleClass('o_mobile_menu_opened');
+ },
+ /**
+ * Called in response to edit mode activation -> hides the navbar.
+ *
+ * @private
+ */
+ _onReadonlyMode: function () {
+ this.$el.removeClass('editing_mode');
+ this.do_show();
+ },
+ /**
+ * Called in response to edit mode saving -> checks if action-capable
+ * children have something to save.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSave: function (ev) {
+ ev.data.defs.push(this._handleAction('on_save'));
+ },
+});
+
+var WebsiteNavbarActionWidget = Widget.extend({
+ /**
+ * 'Action name' -> 'Handler name' object
+ *
+ * Any [data-action="x"] element inside the website navbar will
+ * automatically trigger an action "x". This action can then be handled by
+ * any `WebsiteNavbarActionWidget` instance if the action name "x" is
+ * registered in this `actions` object.
+ */
+ actions: {},
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Checks if the widget can execute an action whose name is given, with the
+ * given parameters. If it is the case, execute that action.
+ *
+ * @param {string} actionName
+ * @param {Array} params
+ * @returns {Promise|null} action's promise or null if no action was found
+ */
+ handleAction: function (actionName, params) {
+ var action = this[this.actions[actionName]];
+ if (action) {
+ return Promise.resolve(action.apply(this, params || []));
+ }
+ return null;
+ },
+});
+
+websiteRootData.websiteRootRegistry.add(WebsiteNavbar, '#oe_main_menu_navbar');
+
+return {
+ WebsiteNavbar: WebsiteNavbar,
+ websiteNavbarRegistry: websiteNavbarRegistry,
+ WebsiteNavbarActionWidget: WebsiteNavbarActionWidget,
+};
+});
diff --git a/addons/website/static/src/js/menu/new_content.js b/addons/website/static/src/js/menu/new_content.js
new file mode 100644
index 00000000..8d541210
--- /dev/null
+++ b/addons/website/static/src/js/menu/new_content.js
@@ -0,0 +1,350 @@
+odoo.define('website.newMenu', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var websiteNavbarData = require('website.navbar');
+var wUtils = require('website.utils');
+var tour = require('web_tour.tour');
+
+const {qweb, _t} = core;
+
+var enableFlag = 'enable_new_content';
+
+var NewContentMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
+ xmlDependencies: ['/website/static/src/xml/website.editor.xml'],
+ actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, {
+ close_all_widgets: '_handleCloseDemand',
+ new_page: '_createNewPage',
+ }),
+ events: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.events || {}, {
+ 'click': '_onBackgroundClick',
+ 'click [data-module-id]': '_onModuleIdClick',
+ 'keydown': '_onBackgroundKeydown',
+ }),
+ // allow text to be customized with inheritance
+ newContentText: {
+ failed: _t('Failed to install "%s"'),
+ installInProgress: _t("The installation of an App is already in progress."),
+ installNeeded: _t('Do you want to install the "%s" App?'),
+ installPleaseWait: _t('Installing "%s"'),
+ },
+
+ /**
+ * Prepare the navigation and find the modules to install.
+ * Move not installed module buttons after installed modules buttons,
+ * but keep the original index to be able to move back the pending install
+ * button at its final position, so the user can click at the same place.
+ *
+ * @override
+ */
+ start: function () {
+ this.pendingInstall = false;
+ this.$newContentMenuChoices = this.$('#o_new_content_menu_choices');
+
+ var $modules = this.$newContentMenuChoices.find('.o_new_content_element');
+ _.each($modules, function (el, index) {
+ var $el = $(el);
+ $el.data('original-index', index);
+ if ($el.data('module-id')) {
+ $el.appendTo($el.parent());
+ $el.find('a i, a p').addClass('o_uninstalled_module');
+ }
+ });
+
+ this.$firstLink = this.$newContentMenuChoices.find('a:eq(0)');
+ this.$lastLink = this.$newContentMenuChoices.find('a:last');
+
+ if ($.deparam.querystring()[enableFlag] !== undefined) {
+ Object.keys(tour.tours).forEach(
+ el => {
+ let element = tour.tours[el];
+ if (element.steps[0].trigger == '#new-content-menu > a'
+ && !element.steps[0].extra_trigger) {
+ element.steps[0].auto = true;
+ }
+ }
+ );
+ this._showMenu();
+ }
+ this.$loader = $(qweb.render('website.new_content_loader'));
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Actions
+ //--------------------------------------------------------------------------
+
+ /**
+ * Asks the user information about a new page to create, then creates it and
+ * redirects the user to this new page.
+ *
+ * @private
+ * @returns {Promise} Unresolved if there is a redirection
+ */
+ _createNewPage: function () {
+ return wUtils.prompt({
+ id: 'editor_new_page',
+ window_title: _t("New Page"),
+ input: _t("Page Title"),
+ init: function () {
+ var $group = this.$dialog.find('div.form-group');
+ $group.removeClass('mb0');
+
+ var $add = $('<div/>', {'class': 'form-group mb0 row'})
+ .append($('<span/>', {'class': 'offset-md-3 col-md-9 text-left'})
+ .append(qweb.render('website.components.switch', {id: 'switch_addTo_menu', label: _t("Add to menu")})));
+ $add.find('input').prop('checked', true);
+ $group.after($add);
+ }
+ }).then(function (result) {
+ var val = result.val;
+ var $dialog = result.dialog;
+ if (!val) {
+ return;
+ }
+ var url = '/website/add/' + encodeURIComponent(val);
+ const res = wUtils.sendRequest(url, {
+ add_menu: $dialog.find('input[type="checkbox"]').is(':checked') || '',
+ });
+ return new Promise(function () {});
+ });
+ },
+ /**
+ * @private
+ */
+ _handleCloseDemand: function () {
+ this._hideMenu();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Set the focus on the first link
+ *
+ * @private
+ */
+ _focusFirstLink: function () {
+ this.$firstLink.focus();
+ },
+ /**
+ * Set the focus on the last link
+ *
+ * @private
+ */
+ _focusLastLink: function () {
+ this.$lastLink.focus();
+ },
+ /**
+ * Hide the menu
+ *
+ * @private
+ */
+ _hideMenu: function () {
+ this.shown = false;
+ this.$newContentMenuChoices.addClass('o_hidden');
+ $('body').removeClass('o_new_content_open');
+ },
+ /**
+ * Install a module
+ *
+ * @private
+ * @param {number} moduleId: the module to install
+ * @return {Promise}
+ */
+ _install: function (moduleId) {
+ this.pendingInstall = true;
+ $('body').css('pointer-events', 'none');
+ return this._rpc({
+ model: 'ir.module.module',
+ method: 'button_immediate_install',
+ args: [[moduleId]],
+ }).guardedCatch(function () {
+ $('body').css('pointer-events', '');
+ });
+ },
+ /**
+ * Show the menu
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _showMenu: function () {
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ self.trigger_up('action_demand', {
+ actionName: 'close_all_widgets',
+ onSuccess: resolve,
+ });
+ }).then(function () {
+ self.firstTab = true;
+ self.shown = true;
+ self.$newContentMenuChoices.removeClass('o_hidden');
+ $('body').addClass('o_new_content_open');
+ self.$('> a').focus();
+
+ wUtils.removeLoader();
+ });
+ },
+ /**
+ * Called to add loader element in DOM.
+ *
+ * @param {string} moduleName
+ * @private
+ */
+ _addLoader(moduleName) {
+ const newContentLoaderText = _.str.sprintf(_t("Building your %s"), moduleName);
+ this.$loader.find('#new_content_loader_text').replaceWith(newContentLoaderText);
+ $('body').append(this.$loader);
+ },
+ /**
+ * @private
+ */
+ _removeLoader() {
+ this.$loader.remove();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the menu's toggle button is clicked:
+ * -> Opens the menu and reset the tab navigation (if closed)
+ * -> Close the menu (if open)
+ * Called when a click outside the menu's options occurs -> Close the menu
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onBackgroundClick: function (ev) {
+ if (this.$newContentMenuChoices.hasClass('o_hidden')) {
+ this._showMenu();
+ } else {
+ this._hideMenu();
+ }
+ },
+ /**
+ * Called when a keydown occurs:
+ * ESC -> Closes the modal
+ * TAB -> Navigation (captured in the modal)
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onBackgroundKeydown: function (ev) {
+ if (!this.shown) {
+ return;
+ }
+ switch (ev.which) {
+ case $.ui.keyCode.ESCAPE:
+ this._hideMenu();
+ ev.stopPropagation();
+ break;
+ case $.ui.keyCode.TAB:
+ if (ev.shiftKey) {
+ if (this.firstTab || document.activeElement === this.$firstLink[0]) {
+ this._focusLastLink();
+ ev.preventDefault();
+ }
+ } else {
+ if (this.firstTab || document.activeElement === this.$lastLink[0]) {
+ this._focusFirstLink();
+ ev.preventDefault();
+ }
+ }
+ this.firstTab = false;
+ break;
+ }
+ },
+ /**
+ * Open the install dialog related to an element:
+ * - open the dialog depending on access right and another pending install
+ * - if ok to install, prepare the install action:
+ * - call the proper action on click
+ * - change the button text and style
+ * - handle the result (reload on the same page or error)
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onModuleIdClick: function (ev) {
+ var self = this;
+ var $el = $(ev.currentTarget);
+ var $i = $el.find('a i');
+ var $p = $el.find('a p');
+
+ var title = $p.text();
+ var content = '';
+ var buttons;
+
+ var moduleId = $el.data('module-id');
+ var name = $el.data('module-shortdesc');
+
+ ev.stopPropagation();
+ ev.preventDefault();
+
+ if (this.pendingInstall) {
+ content = this.newContentText.installInProgress;
+ } else {
+ content = _.str.sprintf(this.newContentText.installNeeded, name);
+ buttons = [{
+ text: _t("Install"),
+ classes: 'btn-primary',
+ close: true,
+ click: function () {
+ // move the element where it will be after installation
+ var $finalPosition = self.$newContentMenuChoices
+ .find('.o_new_content_element:not([data-module-id])')
+ .filter(function () {
+ return $(this).data('original-index') < $el.data('original-index');
+ }).last();
+ if ($finalPosition) {
+ $el.fadeTo(400, 0, function () {
+ // if once installed, button disapeear, don't need to move it.
+ if (!$el.hasClass('o_new_content_element_once')) {
+ $el.insertAfter($finalPosition);
+ }
+ // change style to use spinner
+ $i.removeClass()
+ .addClass('fa fa-spin fa-spinner fa-pulse')
+ .css('background-image', 'none');
+ $p.removeClass('o_uninstalled_module')
+ .text(_.str.sprintf(self.newContentText.installPleaseWait, name));
+ $el.fadeTo(1000, 1);
+ self._addLoader(name);
+ });
+ }
+
+ self._install(moduleId).then(function () {
+ var origin = window.location.origin;
+ var redirectURL = $el.find('a').data('url') || (window.location.pathname + '?' + enableFlag);
+ window.location.href = origin + redirectURL;
+ self._removeLoader();
+ }, function () {
+ $i.removeClass()
+ .addClass('fa fa-exclamation-triangle');
+ $p.text(_.str.sprintf(self.newContentText.failed, name));
+ });
+ }
+ }, {
+ text: _t("Cancel"),
+ close: true,
+ }];
+ }
+
+ new Dialog(this, {
+ title: title,
+ size: 'medium',
+ $content: $('<div/>', {text: content}),
+ buttons: buttons
+ }).open();
+ },
+});
+
+websiteNavbarData.websiteNavbarRegistry.add(NewContentMenu, '.o_new_content_menu');
+
+return NewContentMenu;
+});
diff --git a/addons/website/static/src/js/menu/seo.js b/addons/website/static/src/js/menu/seo.js
new file mode 100644
index 00000000..b724bc1a
--- /dev/null
+++ b/addons/website/static/src/js/menu/seo.js
@@ -0,0 +1,902 @@
+odoo.define('website.seo', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Class = require('web.Class');
+var Dialog = require('web.Dialog');
+var mixins = require('web.mixins');
+var rpc = require('web.rpc');
+var Widget = require('web.Widget');
+var weWidgets = require('wysiwyg.widgets');
+var websiteNavbarData = require('website.navbar');
+
+var _t = core._t;
+
+// This replaces \b, because accents(e.g. à, é) are not seen as word boundaries.
+// 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]+|^|$)';
+
+var Suggestion = Widget.extend({
+ template: 'website.seo_suggestion',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+ events: {
+ 'click .o_seo_suggestion': 'select',
+ },
+
+ init: function (parent, options) {
+ this.keyword = options.keyword;
+ this._super(parent);
+ },
+ select: function () {
+ this.trigger('selected', this.keyword);
+ },
+});
+
+var SuggestionList = Widget.extend({
+ template: 'website.seo_suggestion_list',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+
+ init: function (parent, options) {
+ this.root = options.root;
+ this.language = options.language;
+ this.htmlPage = options.htmlPage;
+ this._super(parent);
+ },
+ start: function () {
+ this.refresh();
+ },
+ refresh: function () {
+ var self = this;
+ self.$el.append(_t("Loading..."));
+ var context;
+ this.trigger_up('context_get', {
+ callback: function (ctx) {
+ context = ctx;
+ },
+ });
+ var language = self.language || context.lang.toLowerCase();
+ this._rpc({
+ route: '/website/seo_suggest',
+ params: {
+ keywords: self.root,
+ lang: language,
+ },
+ }).then(function (keyword_list) {
+ self.addSuggestions(JSON.parse(keyword_list));
+ });
+ },
+ addSuggestions: function (keywords) {
+ var self = this;
+ self.$el.empty();
+ // TODO Improve algorithm + Ajust based on custom user keywords
+ var regex = new RegExp(WORD_SEPARATORS_REGEX + self.root + WORD_SEPARATORS_REGEX, 'gi');
+ keywords = _.map(_.uniq(keywords), function (word) {
+ return word.replace(regex, '').trim();
+ });
+ // TODO Order properly ?
+ _.each(keywords, function (keyword) {
+ if (keyword) {
+ var suggestion = new Suggestion(self, {
+ keyword: keyword,
+ });
+ suggestion.on('selected', self, function (word, language) {
+ self.trigger('selected', word, language);
+ });
+ suggestion.appendTo(self.$el);
+ }
+ });
+ },
+});
+
+var Keyword = Widget.extend({
+ template: 'website.seo_keyword',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+ events: {
+ 'click a[data-action=remove-keyword]': 'destroy',
+ },
+
+ init: function (parent, options) {
+ this.keyword = options.word;
+ this.language = options.language;
+ 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 () {
+ var self = this;
+ this.$('.o_seo_keyword_suggestion').empty();
+ this.suggestionList = new SuggestionList(this, {
+ root: this.keyword,
+ language: this.language,
+ htmlPage: this.htmlPage,
+ });
+ this.suggestionList.on('selected', this, function (word, language) {
+ this.trigger('selected', word, language);
+ });
+ return this.suggestionList.appendTo(this.$('.o_seo_keyword_suggestion')).then(function() {
+ self.htmlPage.on('title-changed', self, self._updateTitle);
+ self.htmlPage.on('description-changed', self, self._updateDescription);
+ self._updateTitle();
+ self._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({
+ template: 'website.seo_list',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+ maxKeywords: 10,
+
+ init: function (parent, options) {
+ this.htmlPage = options.htmlPage;
+ this._super(parent);
+ },
+ start: function () {
+ var self = this;
+ var existingKeywords = self.htmlPage.keywords();
+ if (existingKeywords.length > 0) {
+ _.each(existingKeywords, function (word) {
+ self.add.call(self, word);
+ });
+ }
+ },
+ keywords: function () {
+ var result = [];
+ this.$('.js_seo_keyword').each(function () {
+ result.push($(this).data('keyword'));
+ });
+ return result;
+ },
+ isFull: function () {
+ return this.keywords().length >= this.maxKeywords;
+ },
+ exists: function (word) {
+ return _.contains(this.keywords(), word);
+ },
+ add: async function (candidate, language) {
+ var self = this;
+ // TODO Refine
+ var word = candidate ? candidate.replace(/[,;.:<>]+/g, ' ').replace(/ +/g, ' ').trim().toLowerCase() : '';
+ if (word && !self.isFull() && !self.exists(word)) {
+ var keyword = new Keyword(self, {
+ word: word,
+ language: language,
+ htmlPage: this.htmlPage,
+ });
+ keyword.on('removed', self, function () {
+ self.trigger('list-not-full');
+ self.trigger('content-updated', true);
+ });
+ keyword.on('selected', self, function (word, language) {
+ self.trigger('selected', word, language);
+ });
+ await keyword.appendTo(self.$el);
+ }
+ if (self.isFull()) {
+ self.trigger('list-full');
+ }
+ self.trigger('content-updated');
+ },
+});
+
+var Preview = Widget.extend({
+ template: 'website.seo_preview',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+
+ init: function (parent, options) {
+ this.title = options.title;
+ this.url = options.url;
+ this.description = options.description;
+ 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.defaultTitle = $('meta[name="default_title"]').attr('content');
+ this.initDescription = this.description();
+ },
+ url: function () {
+ return window.location.origin + window.location.pathname;
+ },
+ title: function () {
+ return $('title').text().trim();
+ },
+ changeTitle: function (title) {
+ // TODO create tag if missing
+ $('title').text(title.trim() || this.defaultTitle);
+ this.trigger('title-changed', title);
+ },
+ description: function () {
+ return ($('meta[name=description]').attr('content') || '').trim();
+ },
+ changeDescription: function (description) {
+ // TODO create tag if missing
+ $('meta[name=description]').attr('content', description);
+ this.trigger('description-changed', description);
+ },
+ keywords: function () {
+ var $keywords = $('meta[name=keywords]');
+ var parsed = ($keywords.length > 0) && $keywords.attr('content') && $keywords.attr('content').split(',');
+ return (parsed && parsed[0]) ? parsed: [];
+ },
+ changeKeywords: function (keywords) {
+ // TODO create tag if missing
+ $('meta[name=keywords]').attr('content', keywords.join(','));
+ },
+ headers: function (tag) {
+ return $('#wrap '+tag).map(function () {
+ return $(this).text();
+ });
+ },
+ 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 () {
+ return $('#wrap img').filter(function () {
+ return this.naturalHeight >= 200 && this.naturalWidth >= 200;
+ }).map(function () {
+ return {
+ src: this.getAttribute('src'),
+ alt: this.getAttribute('alt'),
+ };
+ });
+ },
+ company: function () {
+ return $('html').attr('data-oe-company-name');
+ },
+ bodyText: function () {
+ 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());
+ },
+ isInTitle: function (text) {
+ return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.title());
+ },
+ 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 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: {
+ 'input input[name=website_meta_title]': '_titleChanged',
+ 'input input[name=website_seo_name]': '_seoNameChanged',
+ '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.canEditUrl = !!options.canEditUrl;
+ this.isIndexed = !!options.isIndexed;
+ this.seoName = options.seoName;
+ this.seoNameDefault = options.seoNameDefault;
+ this.seoNameHelp = options.seoNameHelp;
+ this.previewDescription = options.previewDescription;
+ this._super(parent, options);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$title = this.$('input[name=website_meta_title]');
+ this.$seoName = this.$('input[name=website_seo_name]');
+ this.$seoNamePre = this.$('span.seo_name_pre');
+ this.$seoNamePost = this.$('span.seo_name_post');
+ this.$description = this.$('textarea[name=website_meta_description]');
+ this.$warning = this.$('div#website_meta_description_warning');
+ this.$preview = this.$('.js_seo_preview');
+
+ if (!this.canEditTitle) {
+ this.$title.attr('disabled', true);
+ }
+ if (!this.canEditDescription) {
+ this.$description.attr('disabled', true);
+ }
+ if (this.htmlPage.title().trim() !== this.htmlPage.defaultTitle.trim()) {
+ this.$title.val(this.htmlPage.title());
+ }
+ if (this.htmlPage.description().trim() !== this.previewDescription) {
+ this.$description.val(this.htmlPage.description());
+ }
+
+ if (this.canEditUrl) {
+ this.previousSeoName = this.seoName;
+ this.$seoName.val(this.seoName);
+ this.$seoName.attr('placeholder', this.seoNameDefault);
+ // make slug editable with input group for static text
+ const splitsUrl = window.location.pathname.split(this.previousSeoName || this.seoNameDefault);
+ this.$seoNamePre.text(splitsUrl[0]);
+ this.$seoNamePost.text(splitsUrl.slice(-1)[0]); // at least the -id theorically
+ }
+ this._descriptionOnChange();
+ },
+ /**
+ * Get the current title
+ */
+ getTitle: function () {
+ return this.$title.val().trim() || this.htmlPage.defaultTitle;
+ },
+ /**
+ * Get the potential new url with custom seoName as slug.
+ I can differ after save if slug JS != slug Python, but it provide an idea for the preview
+ */
+ getUrl: function () {
+ const path = window.location.pathname.replace(
+ this.previousSeoName || this.seoNameDefault,
+ (this.$seoName.length && this.$seoName.val() ? this.$seoName.val().trim() : this.$seoName.attr('placeholder'))
+ );
+ return window.location.origin + path
+ },
+ /**
+ * Get the current description
+ */
+ getDescription: function () {
+ return this.getRealDescription() || this.previewDescription;
+ },
+ /**
+ * Get the current description chosen by the user
+ */
+ getRealDescription: function () {
+ return this.$description.val() || '';
+ },
+ /**
+ * @private
+ */
+ _titleChanged: function () {
+ var self = this;
+ self._renderPreview();
+ self.trigger('title-changed');
+ },
+ /**
+ * @private
+ */
+ _seoNameChanged: function () {
+ var self = this;
+ // don't use _, because we need to keep trailing whitespace during edition
+ const slugified = this.$seoName.val().toString().toLowerCase()
+ .replace(/\s+/g, '-') // Replace spaces with -
+ .replace(/[^\w\-]+/g, '-') // Remove all non-word chars
+ .replace(/\-\-+/g, '-'); // Replace multiple - with single -
+ this.$seoName.val(slugified);
+ self._renderPreview();
+ },
+ /**
+ * @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.getUrl(),
+ });
+ } 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);
+ });
+ return this.keywordList.insertAfter(this.$('.table thead')).then(function() {
+ self._getLanguages();
+ self._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;
+ var context;
+ this.trigger_up('context_get', {
+ callback: function (ctx) {
+ context = ctx;
+ },
+ });
+ this._rpc({
+ route: '/website/get_languages',
+ }).then(function (data) {
+ self.$('#language-box').html(core.qweb.render('Configurator.language_promote', {
+ 'language': data,
+ 'def_lang': context.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({
+ template: 'website.seo_meta_image_selector',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+ events: {
+ 'click .o_meta_img_upload': '_onClickUploadImg',
+ 'click .o_meta_img': '_onClickSelectImg',
+ },
+ /**
+ * @override
+ * @param {widget} parent
+ * @param {Object} data
+ */
+ init: function (parent, data) {
+ this.metaTitle = data.title || '';
+ this.activeMetaImg = data.metaImg;
+ this.serverUrl = data.htmlpage.url();
+ const imgField = data.hasSocialDefaultImage ? 'social_default_image' : 'logo';
+ data.pageImages.unshift(_.str.sprintf('/web/image/website/%s/%s', odoo.session_info.website_id, imgField));
+ this.images = _.uniq(data.pageImages);
+ this.customImgUrl = _.contains(
+ data.pageImages.map((img)=> new URL(img, window.location.origin).pathname),
+ new URL(data.metaImg, window.location.origin).pathname)
+ ? false : data.metaImg;
+ this.previewDescription = data.previewDescription;
+ this._setDescription(this.previewDescription);
+ 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 || this.previewDescription;
+ if (this.metaDescription.length > 160) {
+ this.metaDescription = this.metaDescription.substring(0, 159) + '…';
+ }
+ },
+
+ /**
+ * Update template.
+ *
+ * @private
+ */
+ _updateTemplateBody: function () {
+ this.$el.empty();
+ this.images = _.uniq(this.images);
+ this.$el.append(core.qweb.render('website.og_image_body', {widget: this}));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a select image from list -> change the preview accordingly.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickSelectImg: function (ev) {
+ var $img = $(ev.currentTarget);
+ this.activeMetaImg = $img.find('img').attr('src');
+ this._updateTemplateBody();
+ },
+ /**
+ * Open a mediaDialog to select/upload image.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickUploadImg: function (ev) {
+ var self = this;
+ var $image = $('<img/>');
+ var mediaDialog = new weWidgets.MediaDialog(this, {
+ onlyImages: true,
+ res_model: 'ir.ui.view',
+ }, $image[0]);
+ mediaDialog.open();
+ mediaDialog.on('save', this, function (image) {
+ self.activeMetaImg = image.src;
+ self.customImgUrl = image.src;
+ self._updateTemplateBody();
+ });
+ },
+});
+
+var SeoConfigurator = Dialog.extend({
+ template: 'website.seo_configuration',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/website/static/src/xml/website.seo.xml']
+ ),
+ canEditTitle: false,
+ canEditDescription: false,
+ canEditKeywords: false,
+ canEditLanguage: false,
+ canEditUrl: false,
+
+ init: function (parent, options) {
+ options = options || {};
+ _.defaults(options, {
+ title: _t('Optimize SEO'),
+ buttons: [
+ {text: _t('Save'), classes: 'btn-primary', click: this.update},
+ {text: _t('Discard'), close: true},
+ ],
+ });
+
+ this._super(parent, options);
+ },
+ start: function () {
+ var self = this;
+
+ this.$modal.addClass('oe_seo_configuration');
+
+ this.htmlPage = new HtmlPage();
+
+ this.disableUnsavableFields().then(function () {
+ // Image selector
+ self.metaImageSelector = new MetaImageSelector(self, {
+ htmlpage: self.htmlPage,
+ hasSocialDefaultImage: self.hasSocialDefaultImage,
+ title: self.htmlPage.getOgMeta().metaTitle,
+ metaImg: self.metaImg || self.htmlPage.getOgMeta().ogImageUrl,
+ pageImages: _.pluck(self.htmlPage.images().get(), 'src'),
+ previewDescription: _t('The description will be generated by social media based on page content unless you specify one.'),
+ });
+ self.metaImageSelector.appendTo(self.$('.js_seo_image'));
+
+ // title and description
+ self.metaTitleDescription = new MetaTitleDescription(self, {
+ htmlPage: self.htmlPage,
+ canEditTitle: self.canEditTitle,
+ canEditDescription: self.canEditDescription,
+ canEditUrl: self.canEditUrl,
+ isIndexed: self.isIndexed,
+ previewDescription: _t('The description will be generated by search engines based on page content unless you specify one.'),
+ seoNameHelp: _t('This value will be escaped to be compliant with all major browsers and used in url. Keep it empty to use the default name of the record.'),
+ seoName: self.seoName, // 'my-custom-display-name' or ''
+ seoNameDefault: self.seoNameDefault, // 'display-name'
+ });
+ 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;
+ self.hasSocialDefaultImage = data.has_social_default_image;
+ self.canEditUrl = data && ('seo_name' in data);
+ self.seoName = self.canEditUrl && data.seo_name;
+ self.seoNameDefault = self.canEditUrl && data.seo_name_default;
+ 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);
+ }
+ });
+ },
+ update: function () {
+ var self = this;
+ var data = {};
+ if (this.canEditTitle) {
+ data.website_meta_title = this.metaTitleDescription.$title.val();
+ }
+ if (this.canEditDescription) {
+ data.website_meta_description = this.metaTitleDescription.$description.val();
+ }
+ if (this.canEditKeywords) {
+ data.website_meta_keywords = this.metaKeywords.keywordList.keywords().join(', ');
+ }
+ if (this.canEditUrl) {
+ if (this.metaTitleDescription.$seoName.val() != this.metaTitleDescription.previousSeoName) {
+ data.seo_name = this.metaTitleDescription.$seoName.val();
+ self.reloadOnSave = true; // will force a refresh on old url and redirect to new slug
+ }
+ }
+ data.website_meta_og_img = this.metaImageSelector.activeMetaImg;
+ this.saveMetaData(data).then(function () {
+ // 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 () {
+ var mainObject;
+ this.trigger_up('main_object_request', {
+ callback: function (value) {
+ mainObject = value;
+ },
+ });
+ return mainObject;
+ },
+ getSeoObject: function () {
+ var seoObject;
+ this.trigger_up('seo_object_request', {
+ callback: function (value) {
+ seoObject = value;
+ },
+ });
+ return seoObject;
+ },
+ loadMetaData: function () {
+ var obj = this.getSeoObject() || this.getMainObject();
+ return new Promise(function (resolve, reject) {
+ if (!obj) {
+ // return Promise.reject(new Error("No main_object was found."));
+ resolve(null);
+ } else {
+ rpc.query({
+ route: "/website/get_seo_data",
+ params: {
+ 'res_id': obj.id,
+ 'res_model': obj.model,
+ },
+ }).then(function (data) {
+ var meta = data;
+ meta.model = obj.model;
+ resolve(meta);
+ }).guardedCatch(reject);
+ }
+ });
+ },
+ saveMetaData: function (data) {
+ var obj = this.getSeoObject() || this.getMainObject();
+ if (!obj) {
+ return Promise.reject();
+ } else {
+ return this._rpc({
+ model: obj.model,
+ method: 'write',
+ args: [[obj.id], data],
+ });
+ }
+ },
+ titleChanged: function () {
+ var self = this;
+ _.defer(function () {
+ var title = self.metaTitleDescription.getTitle();
+ self.htmlPage.changeTitle(title);
+ self.metaImageSelector.setTitle(title);
+ });
+ },
+ descriptionChanged: function () {
+ var self = this;
+ _.defer(function () {
+ var description = self.metaTitleDescription.getRealDescription();
+ self.htmlPage.changeDescription(description);
+ self.metaImageSelector.setDescription(description);
+ });
+ },
+});
+
+var SeoMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
+ actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, {
+ 'promote-current-page': '_promoteCurrentPage',
+ }),
+
+ init: function (parent, options) {
+ this._super(parent, options);
+
+ if ($.deparam.querystring().enable_seo !== undefined) {
+ this._promoteCurrentPage();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Actions
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens the SEO configurator dialog.
+ *
+ * @private
+ */
+ _promoteCurrentPage: function () {
+ new SeoConfigurator(this).open();
+ },
+});
+
+websiteNavbarData.websiteNavbarRegistry.add(SeoMenu, '#promote-menu');
+
+return {
+ SeoConfigurator: SeoConfigurator,
+ SeoMenu: SeoMenu,
+};
+});
diff --git a/addons/website/static/src/js/menu/translate.js b/addons/website/static/src/js/menu/translate.js
new file mode 100644
index 00000000..afb2aff2
--- /dev/null
+++ b/addons/website/static/src/js/menu/translate.js
@@ -0,0 +1,88 @@
+odoo.define('website.translateMenu', function (require) {
+'use strict';
+
+var utils = require('web.utils');
+var TranslatorMenu = require('website.editor.menu.translate');
+var websiteNavbarData = require('website.navbar');
+
+var TranslatePageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
+ assetLibs: ['web_editor.compiled_assets_wysiwyg', 'website.compiled_assets_wysiwyg'],
+
+ actions: _.extend({}, websiteNavbarData.WebsiteNavbar.prototype.actions || {}, {
+ edit_master: '_goToMasterPage',
+ translate: '_startTranslateMode',
+ }),
+
+ /**
+ * @override
+ */
+ start: function () {
+ var context;
+ this.trigger_up('context_get', {
+ extra: true,
+ callback: function (ctx) {
+ context = ctx;
+ },
+ });
+ this._mustEditTranslations = context.edit_translations;
+ if (this._mustEditTranslations) {
+ var url = window.location.href.replace(/([?&])&*edit_translations[^&#]*&?/, '\$1');
+ window.history.replaceState({}, null, url);
+
+ this._startTranslateMode();
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Actions
+ //--------------------------------------------------------------------------
+
+ /**
+ * Redirects the user to the same page but in the original language and in
+ * edit mode.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _goToMasterPage: function () {
+ var current = document.createElement('a');
+ current.href = window.location.toString();
+ current.search += (current.search ? '&' : '?') + 'enable_editor=1';
+ // we are in translate mode, the pathname starts with '/<url_code/'
+ current.pathname = current.pathname.substr(Math.max(0, current.pathname.indexOf('/', 1)));
+
+ var link = document.createElement('a');
+ link.href = '/website/lang/default';
+ link.search += (link.search ? '&' : '?') + 'r=' + encodeURIComponent(current.pathname + current.search + current.hash);
+
+ window.location = link.href;
+ return new Promise(function () {});
+ },
+ /**
+ * Redirects the user to the same page in translation mode (or start the
+ * translator is translation mode is already enabled).
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _startTranslateMode: function () {
+ if (!this._mustEditTranslations) {
+ window.location.search += '&edit_translations';
+ return new Promise(function () {});
+ }
+
+ var translator = new TranslatorMenu(this);
+
+ // We don't want the BS dropdown to close
+ // when clicking in a element to translate
+ $('.dropdown-menu').on('click', '.o_editable', function (ev) {
+ ev.stopPropagation();
+ });
+
+ return translator.prependTo(document.body);
+ },
+});
+
+websiteNavbarData.websiteNavbarRegistry.add(TranslatePageMenu, '.o_menu_systray:has([data-action="translate"])');
+});
diff --git a/addons/website/static/src/js/post_link.js b/addons/website/static/src/js/post_link.js
new file mode 100644
index 00000000..222216e5
--- /dev/null
+++ b/addons/website/static/src/js/post_link.js
@@ -0,0 +1,25 @@
+odoo.define('website.post_link', function (require) {
+'use strict';
+
+const publicWidget = require('web.public.widget');
+const wUtils = require('website.utils');
+
+publicWidget.registry.postLink = publicWidget.Widget.extend({
+ selector: '.post_link',
+ events: {
+ 'click': '_onClickPost',
+ },
+ _onClickPost: function (ev) {
+ ev.preventDefault();
+ const url = this.el.dataset.post || this.el.href;
+ let data = {};
+ for (let [key, value] of Object.entries(this.el.dataset)) {
+ if (key.startsWith('post_')) {
+ data[key.slice(5)] = value;
+ }
+ };
+ wUtils.sendRequest(url, data);
+ },
+});
+
+});
diff --git a/addons/website/static/src/js/set_view_track.js b/addons/website/static/src/js/set_view_track.js
new file mode 100644
index 00000000..d94fcddf
--- /dev/null
+++ b/addons/website/static/src/js/set_view_track.js
@@ -0,0 +1,89 @@
+odoo.define('website.set_view_track', function (require) {
+"use strict";
+
+var CustomizeMenu = require('website.customizeMenu');
+var Widget = require('web.Widget');
+
+var TrackPage = Widget.extend({
+ template: 'website.track_page',
+ xmlDependencies: ['/website/static/src/xml/track_page.xml'],
+ events: {
+ 'change #switch-track-page': '_onTrackChange',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.$input = this.$('#switch-track-page');
+ this._isTracked().then((data) => {
+ if (data[0]['track']) {
+ this.track = true;
+ this.$input.attr('checked', 'checked');
+ } else {
+ this.track = false;
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _isTracked: function (val) {
+ var viewid = $('html').data('viewid');
+ if (!viewid) {
+ return Promise.reject();
+ } else {
+ return this._rpc({
+ model: 'ir.ui.view',
+ method: 'read',
+ args: [[viewid], ['track']],
+ });
+ }
+ },
+ /**
+ * @private
+ */
+ _onTrackChange: function (ev) {
+ var checkboxValue = this.$input.is(':checked');
+ if (checkboxValue !== this.track) {
+ this.track = checkboxValue;
+ this._trackPage(checkboxValue);
+ }
+ },
+ /**
+ * @private
+ */
+ _trackPage: function (val) {
+ var viewid = $('html').data('viewid');
+ if (!viewid) {
+ return Promise.reject();
+ } else {
+ return this._rpc({
+ model: 'ir.ui.view',
+ method: 'write',
+ args: [[viewid], {track: val}],
+ });
+ }
+ },
+});
+
+CustomizeMenu.include({
+ _loadCustomizeOptions: function () {
+ var self = this;
+ var def = this._super.apply(this, arguments);
+ return def.then(function () {
+ if (!self.__trackpageLoaded) {
+ self.__trackpageLoaded = true;
+ self.trackPage = new TrackPage(self);
+ self.trackPage.appendTo(self.$el.children('.dropdown-menu'));
+ }
+ });
+ },
+});
+
+});
diff --git a/addons/website/static/src/js/show_password.js b/addons/website/static/src/js/show_password.js
new file mode 100644
index 00000000..a27d1812
--- /dev/null
+++ b/addons/website/static/src/js/show_password.js
@@ -0,0 +1,48 @@
+//
+// This file is meant to allow to switch the type of an input #password
+// from password to text on mousedown on an input group.
+// On mouse down, we see the password in clear text
+// On mouse up, we hide it again.
+//
+odoo.define('website.show_password', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+
+publicWidget.registry.ShowPassword = publicWidget.Widget.extend({
+ selector: '#showPass',
+ events: {
+ 'mousedown': '_onShowText',
+ 'touchstart': '_onShowText',
+ },
+
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super(...arguments);
+ $('body').off(".ShowPassword");
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onShowPassword: function () {
+ this.$el.closest('.input-group').find('#password').attr('type', 'password');
+ },
+ /**
+ * @private
+ */
+ _onShowText: function () {
+ $('body').one('mouseup.ShowPassword touchend.ShowPassword', this._onShowPassword.bind(this));
+ this.$el.closest('.input-group').find('#password').attr('type', 'text');
+ },
+});
+
+return publicWidget.registry.ShowPassword;
+
+});
diff --git a/addons/website/static/src/js/theme_preview_form.js b/addons/website/static/src/js/theme_preview_form.js
new file mode 100644
index 00000000..aada899b
--- /dev/null
+++ b/addons/website/static/src/js/theme_preview_form.js
@@ -0,0 +1,154 @@
+odoo.define('website.theme_preview_form', function (require) {
+"use strict";
+
+var FormController = require('web.FormController');
+var FormView = require('web.FormView');
+var viewRegistry = require('web.view_registry');
+var core = require('web.core');
+var qweb = core.qweb;
+
+/*
+* Common code for theme installation/update handler.
+*/
+const ThemePreviewControllerCommon = {
+ /**
+ * Called to add loading effect and install/pdate the selected theme depending on action.
+ *
+ * @private
+ * @param {number} res_id
+ * @param {String} action
+ */
+ _handleThemeAction(res_id, action) {
+ this.$loader = $(qweb.render('website.ThemePreview.Loader', {
+ 'showTips': action !== 'button_refresh_theme',
+ }));
+ let actionCallback = undefined;
+ this._addLoader();
+ switch (action) {
+ case 'button_choose_theme':
+ actionCallback = result => this.do_action(result);
+ break;
+ case 'button_refresh_theme':
+ actionCallback = () => this._removeLoader();
+ break;
+ }
+ const rpcData = {
+ model: 'ir.module.module',
+ method: action,
+ args: [res_id],
+ context: this.initialState.context,
+ };
+ const rpcOptions = {
+ shadow: true,
+ };
+ this._rpc(rpcData, rpcOptions)
+ .then(actionCallback)
+ .guardedCatch(() => this._removeLoader());
+ },
+ /**
+ * Called to add loader element in DOM.
+ *
+ * @private
+ */
+ _addLoader() {
+ $('body').append(this.$loader);
+ },
+ /**
+ * @private
+ */
+ _removeLoader() {
+ this.$loader.remove();
+ }
+};
+
+var ThemePreviewController = FormController.extend(ThemePreviewControllerCommon, {
+ events: Object.assign({}, FormController.prototype.events, {
+ 'click .o_use_theme': '_onStartNowClick',
+ 'click .o_switch_theme': '_onSwitchThemeClick',
+ 'change input[name="viewer"]': '_onSwitchButtonChange',
+ }),
+ /**
+ * @override
+ */
+ start: function () {
+ this.$el.addClass('o_view_form_theme_preview_controller');
+ return this._super.apply(this, arguments);
+ },
+
+ // -------------------------------------------------------------------------
+ // Public
+ // -------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ renderButtons: function ($node) {
+ this.$buttons = $(qweb.render('website.ThemePreview.Buttons'));
+ if ($node) {
+ $node.html(this.$buttons);
+ }
+ },
+ /**
+ * Overriden to prevent the controller from hiding the buttons
+ * @see FormController
+ *
+ * @override
+ */
+ updateButtons: function () { },
+
+ // -------------------------------------------------------------------------
+ // Private
+ // -------------------------------------------------------------------------
+ /**
+ * Add Switcher View Mobile / Desktop near pager
+ *
+ * @private
+ */
+ _updateControlPanelProps: async function () {
+ const props = this._super(...arguments);
+ const $switchModeButton = $(qweb.render('website.ThemePreview.SwitchModeButton'));
+ this.controlPanelProps.cp_content.$pager = $switchModeButton;
+ return props;
+ },
+
+ // -------------------------------------------------------------------------
+ // Handlers
+ // -------------------------------------------------------------------------
+ /**
+ * Handler called when user click on 'Desktop/Mobile' switcher button.
+ *
+ * @private
+ */
+ _onSwitchButtonChange: function () {
+ this.$('.o_preview_frame').toggleClass('is_mobile');
+ },
+ /**
+ * Handler called when user click on 'Choose another theme' button.
+ *
+ * @private
+ */
+ _onSwitchThemeClick: function () {
+ this.trigger_up('history_back');
+ },
+ /**
+ * Handler called when user click on 'START NOW' button in form view.
+ *
+ * @private
+ */
+ _onStartNowClick: function () {
+ this._handleThemeAction(this.getSelectedIds()[0], 'button_choose_theme');
+ },
+});
+
+var ThemePreviewFormView = FormView.extend({
+ config: _.extend({}, FormView.prototype.config, {
+ Controller: ThemePreviewController
+ }),
+});
+
+viewRegistry.add('theme_preview_form', ThemePreviewFormView);
+
+return {
+ ThemePreviewControllerCommon: ThemePreviewControllerCommon
+}
+});
diff --git a/addons/website/static/src/js/theme_preview_kanban.js b/addons/website/static/src/js/theme_preview_kanban.js
new file mode 100644
index 00000000..a8c573b9
--- /dev/null
+++ b/addons/website/static/src/js/theme_preview_kanban.js
@@ -0,0 +1,61 @@
+odoo.define('website.theme_preview_kanban', function (require) {
+"use strict";
+
+var KanbanController = require('web.KanbanController');
+var KanbanView = require('web.KanbanView');
+var ViewRegistry = require('web.view_registry');
+const ThemePreviewControllerCommon = require('website.theme_preview_form').ThemePreviewControllerCommon;
+var core = require('web.core');
+var _lt = core._lt;
+
+var ThemePreviewKanbanController = KanbanController.extend(ThemePreviewControllerCommon, {
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+
+ // hide pager
+ this.el.classList.add('o_view_kanban_theme_preview_controller');
+
+ // update breacrumb
+ const websiteLink = Object.assign(document.createElement('a'), {
+ className: 'btn btn-secondary ml-3 text-black-75',
+ href: '/',
+ innerHTML: '<i class="fa fa-close"></i>',
+ });
+ const smallBreadcumb = Object.assign(document.createElement('small'), {
+ className: 'mx-2 text-muted',
+ innerHTML: _lt("Don't worry, you can switch later."),
+ });
+ this._controlPanelWrapper.el.querySelector('.o_cp_top .breadcrumb li.active').classList.add('text-black-75');
+ this._controlPanelWrapper.el.querySelector('.o_cp_top').appendChild(websiteLink);
+ this._controlPanelWrapper.el.querySelector('.o_cp_top li').appendChild(smallBreadcumb);
+ },
+ /**
+ * Called when user click on any button in kanban view.
+ * Targeted buttons are selected using name attribute value.
+ *
+ * @override
+ */
+ _onButtonClicked: function (ev) {
+ const attrName = ev.data.attrs.name;
+ if (attrName === 'button_choose_theme' || attrName === 'button_refresh_theme') {
+ this._handleThemeAction(ev.data.record.res_id, attrName);
+ } else {
+ this._super(...arguments);
+ }
+ },
+});
+
+var ThemePreviewKanbanView = KanbanView.extend({
+ withSearchBar: false, // hide searchBar
+
+ config: _.extend({}, KanbanView.prototype.config, {
+ Controller: ThemePreviewKanbanController,
+ }),
+});
+
+ViewRegistry.add('theme_preview_kanban', ThemePreviewKanbanView);
+
+});
diff --git a/addons/website/static/src/js/tours/homepage.js b/addons/website/static/src/js/tours/homepage.js
new file mode 100644
index 00000000..3b4c340f
--- /dev/null
+++ b/addons/website/static/src/js/tours/homepage.js
@@ -0,0 +1,47 @@
+odoo.define("website.tour.homepage", function (require) {
+"use strict";
+
+const wTourUtils = require("website.tour_utils");
+
+const snippets = [
+ {
+ id: 's_cover',
+ name: 'Cover',
+ },
+ {
+ id: 's_text_image',
+ name: 'Text - Image',
+ },
+ {
+ id: 's_three_columns',
+ name: 'Columns',
+ },
+ {
+ id: 's_picture',
+ name: 'Picture',
+ },
+ {
+ id: 's_quotes_carousel',
+ name: 'Quotes',
+ },
+ {
+ id: 's_call_to_action',
+ name: 'Call to Action',
+ },
+];
+
+wTourUtils.registerThemeHomepageTour('homepage', [
+ wTourUtils.dragNDrop(snippets[0]),
+ wTourUtils.clickOnText(snippets[0], 'h1'),
+ wTourUtils.goBackToBlocks(),
+ wTourUtils.dragNDrop(snippets[1]),
+ wTourUtils.dragNDrop(snippets[2]),
+ wTourUtils.dragNDrop(snippets[3]),
+ wTourUtils.dragNDrop(snippets[4]),
+ wTourUtils.dragNDrop(snippets[5]),
+ wTourUtils.clickOnSnippet(snippets[5], 'top'),
+ wTourUtils.changeBackgroundColor(),
+ wTourUtils.clickOnSave(),
+]);
+
+});
diff --git a/addons/website/static/src/js/tours/tour_utils.js b/addons/website/static/src/js/tours/tour_utils.js
new file mode 100644
index 00000000..6a0d23d9
--- /dev/null
+++ b/addons/website/static/src/js/tours/tour_utils.js
@@ -0,0 +1,291 @@
+odoo.define("website.tour_utils", function (require) {
+"use strict";
+
+const core = require("web.core");
+const _t = core._t;
+
+var tour = require("web_tour.tour");
+
+/**
+
+const snippets = [
+ {
+ id: 's_cover',
+ name: 'Cover',
+ },
+ {
+ id: 's_text_image',
+ name: 'Text - Image',
+ }
+];
+
+tour.register("themename_tour", {
+ url: "/",
+ saveAs: "homepage",
+}, [
+ wTourUtils.dragNDrop(snippets[0]),
+ wTourUtils.clickOnText(snippets[0], 'h1'),
+ wTourUtils.changeOption('colorFilter', 'span.o_we_color_preview', _t('color filter')),
+ wTourUtils.selectHeader(),
+ wTourUtils.changeOption('HeaderTemplate', '[data-name="header_alignment_opt"]', _t('alignment')),
+ wTourUtils.goBackToBlocks(),
+ wTourUtils.dragNDrop(snippets[1]),
+ wTourUtils.changeImage(snippets[1]),
+ wTourUtils.clickOnSave(),
+]);
+**/
+
+
+
+function addMedia(position = "right") {
+ return {
+ trigger: `.modal-content footer .btn-primary`,
+ content: _t("<b>Add</b> the selected image."),
+ position: position,
+ run: "click",
+ };
+}
+
+function changeBackground(snippet, position = "bottom") {
+ return {
+ trigger: ".o_we_customize_panel .o_we_edit_image",
+ content: _t("<b>Customize</b> any block through this menu. Try to change the background image of this block."),
+ position: position,
+ run: "click",
+ };
+}
+
+function changeBackgroundColor(position = "bottom") {
+ return {
+ trigger: ".o_we_customize_panel .o_we_color_preview",
+ content: _t("<b>Customize</b> any block through this menu. Try to change the background color of this block."),
+ position: position,
+ run: "click",
+ };
+}
+
+function selectColorPalette(position = "left") {
+ return {
+ trigger: ".o_we_customize_panel .o_we_so_color_palette we-selection-items",
+ alt_trigger: ".o_we_customize_panel .o_we_color_preview",
+ content: _t(`<b>Select</b> a Color Palette.`),
+ position: position,
+ run: 'click',
+ location: position === 'left' ? '#oe_snippets' : undefined,
+ };
+}
+
+function changeColumnSize(position = "right") {
+ return {
+ trigger: `.oe_overlay.ui-draggable.o_we_overlay_sticky.oe_active .o_handle.e`,
+ content: _t("<b>Slide</b> this button to change the column size."),
+ position: position,
+ };
+}
+
+function changeIcon(snippet, index = 0, position = "bottom") {
+ return {
+ trigger: `#wrapwrap .${snippet.id} i:eq(${index})`,
+ content: _t("<b>Double click on an icon</b> to change it with one of your choice."),
+ position: position,
+ run: "dblclick",
+ };
+}
+
+function changeImage(snippet, position = "bottom") {
+ return {
+ trigger: `#wrapwrap .${snippet.id} img`,
+ content: _t("<b>Double click on an image</b> to change it with one of your choice."),
+ position: position,
+ run: "dblclick",
+ };
+}
+
+/**
+ wTourUtils.changeOption('HeaderTemplate', '[data-name="header_alignment_opt"]', _t('alignment')),
+*/
+function changeOption(optionName, weName = '', optionTooltipLabel = '', position = "bottom") {
+ const option_block = `we-customizeblock-option[class='snippet-option-${optionName}']`
+ return {
+ trigger: `${option_block} ${weName}, ${option_block} [title='${weName}']`,
+ content: _.str.sprintf(_t("<b>Click</b> on this option to change the %s of the block."), optionTooltipLabel),
+ position: position,
+ run: "click",
+ };
+}
+
+function selectNested(trigger, optionName, alt_trigger = null, optionTooltipLabel = '', position = "top") {
+ const option_block = `we-customizeblock-option[class='snippet-option-${optionName}']`;
+ return {
+ trigger: trigger,
+ content: _.str.sprintf(_t("<b>Select</b> a %s."), optionTooltipLabel),
+ alt_trigger: alt_trigger == null ? undefined : `${option_block} ${alt_trigger}`,
+ position: position,
+ run: 'click',
+ location: position === 'left' ? '#oe_snippets' : undefined,
+ };
+}
+
+function changePaddingSize(direction) {
+ let paddingDirection = "n";
+ let position = "top";
+ if (direction === "bottom") {
+ paddingDirection = "s";
+ position = "bottom";
+ }
+ return {
+ trigger: `.oe_overlay.ui-draggable.o_we_overlay_sticky.oe_active .o_handle.${paddingDirection}`,
+ content: _.str.sprintf(_t("<b>Slide</b> this button to change the %s padding"), direction),
+ position: position,
+ };
+}
+
+/**
+ * Click on the top right edit button
+ * @param {*} position Where the purple arrow will show up
+ */
+function clickOnEdit(position = "bottom") {
+ return {
+ trigger: "a[data-action=edit]",
+ content: _t("<b>Click Edit</b> to start designing your homepage."),
+ extra_trigger: ".homepage",
+ position: position,
+ };
+}
+
+/**
+ * Simple click on a snippet in the edition area
+ * @param {*} snippet
+ * @param {*} position
+ */
+function clickOnSnippet(snippet, position = "bottom") {
+ return {
+ trigger: `#wrapwrap .${snippet.id}`,
+ content: _t("<b>Click on a snippet</b> to access its options menu."),
+ position: position,
+ run: "click",
+ };
+}
+
+function clickOnSave(position = "bottom") {
+ return {
+ trigger: "button[data-action=save]",
+ in_modal: false,
+ content: _t("Good job! It's time to <b>Save</b> your work."),
+ position: position,
+ };
+}
+
+/**
+ * Click on a snippet's text to modify its content
+ * @param {*} snippet
+ * @param {*} element Target the element which should be rewrite
+ * @param {*} position
+ */
+function clickOnText(snippet, element, position = "bottom") {
+ return {
+ trigger: `#wrapwrap .${snippet.id} ${element}`,
+ content: _t("<b>Click on a text</b> to start editing it."),
+ position: position,
+ run: "text",
+ consumeEvent: "input",
+ };
+}
+
+/**
+ * Drag a snippet from the Blocks area and drop it in the Edit area
+ * @param {*} snippet contain the id and the name of the targeted snippet
+ * @param {*} position Where the purple arrow will show up
+ */
+function dragNDrop(snippet, position = "bottom") {
+ return {
+ trigger: `#oe_snippets .oe_snippet[name="${snippet.name}"] .oe_snippet_thumbnail:not(.o_we_already_dragging)`,
+ extra_trigger: "body.editor_enable.editor_has_snippets",
+ moveTrigger: '.oe_drop_zone',
+ content: _.str.sprintf(_t("Drag the <b>%s</b> building block and drop it at the bottom of the page."), snippet.name),
+ position: position,
+ run: "drag_and_drop #wrap",
+ };
+}
+
+function goBackToBlocks(position = "bottom") {
+ return {
+ trigger: '.o_we_add_snippet_btn',
+ content: _t("Click here to go back to block tab."),
+ position: position,
+ run: "click",
+ };
+}
+
+function goToOptions(position = "bottom") {
+ return {
+ trigger: '.o_we_customize_theme_btn',
+ content: _t("Go to the Options tab"),
+ position: position,
+ run: "click",
+ };
+}
+
+function selectHeader(position = "bottom") {
+ return {
+ trigger: `header#top`,
+ content: _t(`<b>Click</b> on this header to configure it.`),
+ position: position,
+ run: "click",
+ };
+}
+
+function selectSnippetColumn(snippet, index = 0, position = "bottom") {
+ return {
+ trigger: `#wrapwrap .${snippet.id} .row div[class*="col-lg-"]:eq(${index})`,
+ content: _t("<b>Click</b> on this column to access its options."),
+ position: position,
+ run: "click",
+ };
+}
+
+function prepend_trigger(steps, prepend_text='') {
+ for (const step of steps) {
+ if (!step.noPrepend && prepend_text) {
+ step.trigger = prepend_text + step.trigger;
+ }
+ }
+ return steps;
+}
+
+function registerThemeHomepageTour(name, steps) {
+ tour.register(name, {
+ url: "/?enable_editor=1",
+ sequence: 1010,
+ saveAs: "homepage",
+ }, prepend_trigger(
+ steps,
+ "html[data-view-xmlid='website.homepage'] "
+ ));
+}
+
+
+return {
+ addMedia,
+ changeBackground,
+ changeBackgroundColor,
+ changeColumnSize,
+ changeIcon,
+ changeImage,
+ changeOption,
+ changePaddingSize,
+ clickOnEdit,
+ clickOnSave,
+ clickOnSnippet,
+ clickOnText,
+ dragNDrop,
+ goBackToBlocks,
+ goToOptions,
+ selectColorPalette,
+ selectHeader,
+ selectNested,
+ selectSnippetColumn,
+
+ registerThemeHomepageTour,
+};
+});
diff --git a/addons/website/static/src/js/user_custom_javascript.js b/addons/website/static/src/js/user_custom_javascript.js
new file mode 100644
index 00000000..a68d298b
--- /dev/null
+++ b/addons/website/static/src/js/user_custom_javascript.js
@@ -0,0 +1,24 @@
+//
+// This file is meant to regroup your javascript code. You can either copy/past
+// any code that should be executed on each page loading or write your own
+// taking advantage of the Odoo framework to create new behaviors or modify
+// existing ones. For example, doing this will greet any visitor with a 'Hello,
+// world !' message in a popup:
+//
+/*
+odoo.define('website.user_custom_code', function (require) {
+'use strict';
+
+var Dialog = require('web.Dialog');
+var publicWidget = require('web.public.widget');
+
+publicWidget.registry.HelloWorldPopup = publicWidget.Widget.extend({
+ selector: '#wrapwrap',
+
+ start: function () {
+ Dialog.alert(this, "Hello, world!");
+ return this._super.apply(this, arguments);
+ },
+})
+});
+*/
diff --git a/addons/website/static/src/js/utils.js b/addons/website/static/src/js/utils.js
new file mode 100644
index 00000000..1c0edf43
--- /dev/null
+++ b/addons/website/static/src/js/utils.js
@@ -0,0 +1,295 @@
+odoo.define('website.utils', function (require) {
+'use strict';
+
+var ajax = require('web.ajax');
+var core = require('web.core');
+
+var qweb = core.qweb;
+
+/**
+ * Allows to load anchors from a page.
+ *
+ * @param {string} url
+ * @returns {Deferred<string[]>}
+ */
+function loadAnchors(url) {
+ return new Promise(function (resolve, reject) {
+ if (url === window.location.pathname || url[0] === '#') {
+ resolve(document.body.outerHTML);
+ } else if (url.length && !url.startsWith("http")) {
+ $.get(window.location.origin + url).then(resolve, reject);
+ } else { // avoid useless query
+ resolve();
+ }
+ }).then(function (response) {
+ return _.map($(response).find('[id][data-anchor=true]'), function (el) {
+ return '#' + el.id;
+ });
+ }).catch(error => {
+ console.debug(error);
+ return [];
+ });
+}
+
+/**
+ * Allows the given input to propose existing website URLs.
+ *
+ * @param {ServicesMixin|Widget} self - an element capable to trigger an RPC
+ * @param {jQuery} $input
+ */
+function autocompleteWithPages(self, $input, options) {
+ $.widget("website.urlcomplete", $.ui.autocomplete, {
+ options: options || {},
+ _create: function () {
+ this._super();
+ this.widget().menu("option", "items", "> :not(.ui-autocomplete-category)");
+ },
+ _renderMenu: function (ul, items) {
+ const self = this;
+ items.forEach(item => {
+ if (item.separator) {
+ self._renderSeparator(ul, item);
+ }
+ else {
+ self._renderItem(ul, item);
+ }
+ });
+ },
+ _renderSeparator: function (ul, item) {
+ return $("<li class='ui-autocomplete-category font-weight-bold text-capitalize p-2'>")
+ .append(`<div>${item.separator}</div>`)
+ .appendTo(ul);
+ },
+ _renderItem: function (ul, item) {
+ return $("<li>")
+ .data('ui-autocomplete-item', item)
+ .append(`<div>${item.label}</div>`)
+ .appendTo(ul);
+ },
+ });
+ $input.urlcomplete({
+ source: function (request, response) {
+ if (request.term[0] === '#') {
+ loadAnchors(request.term).then(function (anchors) {
+ response(anchors);
+ });
+ } else if (request.term.startsWith('http') || request.term.length === 0) {
+ // avoid useless call to /website/get_suggested_links
+ response();
+ } else {
+ return self._rpc({
+ route: '/website/get_suggested_links',
+ params: {
+ needle: request.term,
+ limit: 15,
+ }
+ }).then(function (res) {
+ let choices = res.matching_pages;
+ res.others.forEach(other => {
+ if (other.values.length) {
+ choices = choices.concat(
+ [{separator: other.title}],
+ other.values,
+ );
+ }
+ });
+ response(choices);
+ });
+ }
+ },
+ select: function (ev, ui) {
+ // choose url in dropdown with arrow change ev.target.value without trigger_up
+ // so cannot check here if value has been updated
+ ev.target.value = ui.item.value;
+ self.trigger_up('website_url_chosen');
+ ev.preventDefault();
+ },
+ });
+}
+
+/**
+ * @param {jQuery} $element
+ * @param {jQuery} [$excluded]
+ */
+function onceAllImagesLoaded($element, $excluded) {
+ var defs = _.map($element.find('img').addBack('img'), function (img) {
+ if (img.complete || $excluded && ($excluded.is(img) || $excluded.has(img).length)) {
+ return; // Already loaded
+ }
+ var def = new Promise(function (resolve, reject) {
+ $(img).one('load', function () {
+ resolve();
+ });
+ });
+ return def;
+ });
+ return Promise.all(defs);
+}
+
+/**
+ * @deprecated
+ * @todo create Dialog.prompt instead of this
+ */
+function prompt(options, _qweb) {
+ /**
+ * A bootstrapped version of prompt() albeit asynchronous
+ * This was built to quickly prompt the user with a single field.
+ * For anything more complex, please use editor.Dialog class
+ *
+ * Usage Ex:
+ *
+ * website.prompt("What... is your quest ?").then(function (answer) {
+ * arthur.reply(answer || "To seek the Holy Grail.");
+ * });
+ *
+ * website.prompt({
+ * select: "Please choose your destiny",
+ * init: function () {
+ * return [ [0, "Sub-Zero"], [1, "Robo-Ky"] ];
+ * }
+ * }).then(function (answer) {
+ * mame_station.loadCharacter(answer);
+ * });
+ *
+ * @param {Object|String} options A set of options used to configure the prompt or the text field name if string
+ * @param {String} [options.window_title=''] title of the prompt modal
+ * @param {String} [options.input] tell the modal to use an input text field, the given value will be the field title
+ * @param {String} [options.textarea] tell the modal to use a textarea field, the given value will be the field title
+ * @param {String} [options.select] tell the modal to use a select box, the given value will be the field title
+ * @param {Object} [options.default=''] default value of the field
+ * @param {Function} [options.init] optional function that takes the `field` (enhanced with a fillWith() method) and the `dialog` as parameters [can return a promise]
+ */
+ if (typeof options === 'string') {
+ options = {
+ text: options
+ };
+ }
+ var xmlDef;
+ if (_.isUndefined(_qweb)) {
+ _qweb = 'website.prompt';
+ xmlDef = ajax.loadXML('/website/static/src/xml/website.xml', core.qweb);
+ }
+ options = _.extend({
+ window_title: '',
+ field_name: '',
+ 'default': '', // dict notation for IE<9
+ init: function () {},
+ }, options || {});
+
+ var type = _.intersection(Object.keys(options), ['input', 'textarea', 'select']);
+ type = type.length ? type[0] : 'input';
+ options.field_type = type;
+ options.field_name = options.field_name || options[type];
+
+ var def = new Promise(function (resolve, reject) {
+ Promise.resolve(xmlDef).then(function () {
+ var dialog = $(qweb.render(_qweb, options)).appendTo('body');
+ options.$dialog = dialog;
+ var field = dialog.find(options.field_type).first();
+ field.val(options['default']); // dict notation for IE<9
+ field.fillWith = function (data) {
+ if (field.is('select')) {
+ var select = field[0];
+ data.forEach(function (item) {
+ select.options[select.options.length] = new window.Option(item[1], item[0]);
+ });
+ } else {
+ field.val(data);
+ }
+ };
+ var init = options.init(field, dialog);
+ Promise.resolve(init).then(function (fill) {
+ if (fill) {
+ field.fillWith(fill);
+ }
+ dialog.modal('show');
+ field.focus();
+ dialog.on('click', '.btn-primary', function () {
+ var backdrop = $('.modal-backdrop');
+ resolve({ val: field.val(), field: field, dialog: dialog });
+ dialog.modal('hide').remove();
+ backdrop.remove();
+ });
+ });
+ dialog.on('hidden.bs.modal', function () {
+ var backdrop = $('.modal-backdrop');
+ reject();
+ dialog.remove();
+ backdrop.remove();
+ });
+ if (field.is('input[type="text"], select')) {
+ field.keypress(function (e) {
+ if (e.which === 13) {
+ e.preventDefault();
+ dialog.find('.btn-primary').trigger('click');
+ }
+ });
+ }
+ });
+ });
+
+ return def;
+}
+
+function websiteDomain(self) {
+ var websiteID;
+ self.trigger_up('context_get', {
+ callback: function (ctx) {
+ websiteID = ctx['website_id'];
+ },
+ });
+ return ['|', ['website_id', '=', false], ['website_id', '=', websiteID]];
+}
+
+function sendRequest(route, params) {
+ function _addInput(form, name, value) {
+ let param = document.createElement('input');
+ param.setAttribute('type', 'hidden');
+ param.setAttribute('name', name);
+ param.setAttribute('value', value);
+ form.appendChild(param);
+ }
+
+ let form = document.createElement('form');
+ form.setAttribute('action', route);
+ form.setAttribute('method', params.method || 'POST');
+
+ if (core.csrf_token) {
+ _addInput(form, 'csrf_token', core.csrf_token);
+ }
+
+ for (const key in params) {
+ const value = params[key];
+ if (Array.isArray(value) && value.length) {
+ for (const val of value) {
+ _addInput(form, key, val);
+ }
+ } else {
+ _addInput(form, key, value);
+ }
+ }
+
+ document.body.appendChild(form);
+ form.submit();
+}
+
+/**
+ * Removes the navigation-blocking fullscreen loader from the DOM
+ */
+function removeLoader() {
+ const $loader = $('#o_website_page_loader');
+ if ($loader) {
+ $loader.remove();
+ }
+}
+
+return {
+ loadAnchors: loadAnchors,
+ autocompleteWithPages: autocompleteWithPages,
+ onceAllImagesLoaded: onceAllImagesLoaded,
+ prompt: prompt,
+ sendRequest: sendRequest,
+ websiteDomain: websiteDomain,
+ removeLoader: removeLoader,
+};
+});
diff --git a/addons/website/static/src/js/visitor_timezone.js b/addons/website/static/src/js/visitor_timezone.js
new file mode 100644
index 00000000..b8441bea
--- /dev/null
+++ b/addons/website/static/src/js/visitor_timezone.js
@@ -0,0 +1 @@
+// To remove after v14.0 \ No newline at end of file
diff --git a/addons/website/static/src/js/widget_iframe.js b/addons/website/static/src/js/widget_iframe.js
new file mode 100644
index 00000000..8829ffa8
--- /dev/null
+++ b/addons/website/static/src/js/widget_iframe.js
@@ -0,0 +1,28 @@
+odoo.define('website.iframe_widget', function (require) {
+"use strict";
+
+
+var AbstractField = require('web.AbstractField');
+var core = require('web.core');
+var fieldRegistry = require('web.field_registry');
+
+var QWeb = core.qweb;
+
+/**
+ * Display iframe
+ */
+var FieldIframePreview = AbstractField.extend({
+ className: 'd-block o_field_iframe_preview m-0 h-100',
+
+ _render: function () {
+ this.$el.html(QWeb.render('website.iframeWidget', {
+ url: this.value,
+ }));
+ },
+});
+
+fieldRegistry.add('iframe', FieldIframePreview);
+
+return FieldIframePreview;
+
+});
diff --git a/addons/website/static/src/js/widgets/ace.js b/addons/website/static/src/js/widgets/ace.js
new file mode 100644
index 00000000..177a4db3
--- /dev/null
+++ b/addons/website/static/src/js/widgets/ace.js
@@ -0,0 +1,92 @@
+odoo.define("website.ace", function (require) {
+"use strict";
+
+var AceEditor = require('web_editor.ace');
+
+/**
+ * Extends the default view editor so that the URL hash is updated with view ID
+ */
+var WebsiteAceEditor = AceEditor.extend({
+ hash: '#advanced-view-editor',
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ do_hide: function () {
+ this._super.apply(this, arguments);
+ window.location.hash = "";
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _displayResource: function () {
+ this._super.apply(this, arguments);
+ this._updateHash();
+ },
+ /**
+ * @override
+ */
+ _saveResources: function () {
+ return this._super.apply(this, arguments).then((function () {
+ var defs = [];
+ if (this.currentType === 'xml') {
+ // When saving a view, the view ID might change. Thus, the
+ // active ID in the URL will be incorrect. After the save
+ // reload, that URL ID won't be found and JS will crash.
+ // We need to find the new ID (either because the view became
+ // specific or because its parent was edited too and the view
+ // got copy/unlink).
+ var selectedView = _.findWhere(this.views, {id: this._getSelectedResource()});
+ var context;
+ this.trigger_up('context_get', {
+ callback: function (ctx) {
+ context = ctx;
+ },
+ });
+ defs.push(this._rpc({
+ model: 'ir.ui.view',
+ method: 'search_read',
+ fields: ['id'],
+ domain: [['key', '=', selectedView.key], ['website_id', '=', context.website_id]],
+ }).then((function (view) {
+ if (view[0]) {
+ this._updateHash(view[0].id);
+ }
+ }).bind(this)));
+ }
+ return Promise.all(defs).then((function () {
+ window.location.reload();
+ return new Promise(function () {});
+ }));
+ }).bind(this));
+ },
+ /**
+ * @override
+ */
+ _resetResource: function () {
+ return this._super.apply(this, arguments).then((function () {
+ window.location.reload();
+ return new Promise(function () {});
+ }).bind(this));
+ },
+ /**
+ * Adds the current resource ID in the URL.
+ *
+ * @private
+ */
+ _updateHash: function (resID) {
+ window.location.hash = this.hash + "?res=" + (resID || this._getSelectedResource());
+ },
+});
+
+return WebsiteAceEditor;
+});
diff --git a/addons/website/static/src/js/widgets/media.js b/addons/website/static/src/js/widgets/media.js
new file mode 100644
index 00000000..900f5306
--- /dev/null
+++ b/addons/website/static/src/js/widgets/media.js
@@ -0,0 +1,14 @@
+odoo.define('website.widgets.media', function (require) {
+'use strict';
+
+const {ImageWidget} = require('wysiwyg.widgets.media');
+
+ImageWidget.include({
+ _getAttachmentsDomain() {
+ const domain = this._super(...arguments);
+ domain.push('|', ['url', '=', false], '!', ['url', '=like', '/web/image/website.%']);
+ domain.push(['key', '=', false]);
+ return domain;
+ }
+});
+});