From 3751379f1e9a4c215fb6eb898b4ccc67659b9ace Mon Sep 17 00:00:00 2001 From: stephanchrst Date: Tue, 10 May 2022 21:51:50 +0700 Subject: initial commit 2 --- addons/website/static/src/js/backend/button.js | 116 + addons/website/static/src/js/backend/dashboard.js | 721 ++++++ .../static/src/js/backend/res_config_settings.js | 81 + .../website/static/src/js/content/compatibility.js | 38 + addons/website/static/src/js/content/menu.js | 642 +++++ .../website/static/src/js/content/ripple_effect.js | 72 + .../static/src/js/content/snippets.animation.js | 1092 ++++++++ .../website/static/src/js/content/website_root.js | 350 +++ .../static/src/js/content/website_root_instance.js | 26 + addons/website/static/src/js/editor/editor.js | 18 + addons/website/static/src/js/editor/editor_menu.js | 256 ++ .../static/src/js/editor/editor_menu_translate.js | 109 + addons/website/static/src/js/editor/mega_menu.js | 0 .../website/static/src/js/editor/rte.summernote.js | 59 + .../static/src/js/editor/snippets.editor.js | 245 ++ .../static/src/js/editor/snippets.options.js | 2612 ++++++++++++++++++++ addons/website/static/src/js/editor/widget_link.js | 104 + .../static/src/js/editor/wysiwyg_multizone.js | 286 +++ .../src/js/editor/wysiwyg_multizone_translate.js | 301 +++ addons/website/static/src/js/menu/content.js | 1129 +++++++++ addons/website/static/src/js/menu/customize.js | 219 ++ addons/website/static/src/js/menu/debug_manager.js | 21 + addons/website/static/src/js/menu/edit.js | 256 ++ addons/website/static/src/js/menu/mobile_view.js | 68 + addons/website/static/src/js/menu/navbar.js | 292 +++ addons/website/static/src/js/menu/new_content.js | 350 +++ addons/website/static/src/js/menu/seo.js | 902 +++++++ addons/website/static/src/js/menu/translate.js | 88 + addons/website/static/src/js/post_link.js | 25 + addons/website/static/src/js/set_view_track.js | 89 + addons/website/static/src/js/show_password.js | 48 + addons/website/static/src/js/theme_preview_form.js | 154 ++ .../website/static/src/js/theme_preview_kanban.js | 61 + addons/website/static/src/js/tours/homepage.js | 47 + addons/website/static/src/js/tours/tour_utils.js | 291 +++ .../static/src/js/user_custom_javascript.js | 24 + addons/website/static/src/js/utils.js | 295 +++ addons/website/static/src/js/visitor_timezone.js | 1 + addons/website/static/src/js/widget_iframe.js | 28 + addons/website/static/src/js/widgets/ace.js | 92 + addons/website/static/src/js/widgets/media.js | 14 + 41 files changed, 11622 insertions(+) create mode 100644 addons/website/static/src/js/backend/button.js create mode 100644 addons/website/static/src/js/backend/dashboard.js create mode 100644 addons/website/static/src/js/backend/res_config_settings.js create mode 100644 addons/website/static/src/js/content/compatibility.js create mode 100644 addons/website/static/src/js/content/menu.js create mode 100644 addons/website/static/src/js/content/ripple_effect.js create mode 100644 addons/website/static/src/js/content/snippets.animation.js create mode 100644 addons/website/static/src/js/content/website_root.js create mode 100644 addons/website/static/src/js/content/website_root_instance.js create mode 100644 addons/website/static/src/js/editor/editor.js create mode 100644 addons/website/static/src/js/editor/editor_menu.js create mode 100644 addons/website/static/src/js/editor/editor_menu_translate.js create mode 100644 addons/website/static/src/js/editor/mega_menu.js create mode 100644 addons/website/static/src/js/editor/rte.summernote.js create mode 100644 addons/website/static/src/js/editor/snippets.editor.js create mode 100644 addons/website/static/src/js/editor/snippets.options.js create mode 100644 addons/website/static/src/js/editor/widget_link.js create mode 100644 addons/website/static/src/js/editor/wysiwyg_multizone.js create mode 100644 addons/website/static/src/js/editor/wysiwyg_multizone_translate.js create mode 100644 addons/website/static/src/js/menu/content.js create mode 100644 addons/website/static/src/js/menu/customize.js create mode 100644 addons/website/static/src/js/menu/debug_manager.js create mode 100644 addons/website/static/src/js/menu/edit.js create mode 100644 addons/website/static/src/js/menu/mobile_view.js create mode 100644 addons/website/static/src/js/menu/navbar.js create mode 100644 addons/website/static/src/js/menu/new_content.js create mode 100644 addons/website/static/src/js/menu/seo.js create mode 100644 addons/website/static/src/js/menu/translate.js create mode 100644 addons/website/static/src/js/post_link.js create mode 100644 addons/website/static/src/js/set_view_track.js create mode 100644 addons/website/static/src/js/show_password.js create mode 100644 addons/website/static/src/js/theme_preview_form.js create mode 100644 addons/website/static/src/js/theme_preview_kanban.js create mode 100644 addons/website/static/src/js/tours/homepage.js create mode 100644 addons/website/static/src/js/tours/tour_utils.js create mode 100644 addons/website/static/src/js/user_custom_javascript.js create mode 100644 addons/website/static/src/js/utils.js create mode 100644 addons/website/static/src/js/visitor_timezone.js create mode 100644 addons/website/static/src/js/widget_iframe.js create mode 100644 addons/website/static/src/js/widgets/ace.js create mode 100644 addons/website/static/src/js/widgets/media.js (limited to 'addons/website/static/src/js') 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 = $('').addClass('o_stat_text o_not_hover ' + valColor).text(text); + var $hover = $('').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 = $('
', {class: 'o_graph_canvas_container'}); + this.$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 = $('
').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 = $('
'); + 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 = $('
').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 = $('
').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 = $('
').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: + '
' + + 'Active Users: ' + + '
' + + }); + }, + + // Utility functions + addLoader: function(selector) { + var loader = ''; + selector.html("
" + loader + "
"); + }, + 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