diff options
29 files changed, 1092 insertions, 2 deletions
diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index d121fbbc358..085a4aabe00 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -75,6 +75,8 @@ 'views/snippets/s_mega_menu_multi_menus.xml', 'views/snippets/s_mega_menu_menu_image_menu.xml', 'views/snippets/s_google_map.xml', + 'views/snippets/s_dynamic_snippet.xml', + 'views/snippets/s_dynamic_snippet_carousel.xml', 'views/website_views.xml', 'views/website_visitor_views.xml', 'views/res_config_settings_views.xml', diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index d5f242e02f9..82c26ca0344 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -269,6 +269,21 @@ class Website(Home): ] } + @http.route('/website/snippet/filters', type='json', auth='public', website=True) + def get_dynamic_filter(self, filter_id, template_key, limit=None, search_domain=None): + dynamic_filter = request.env['website.snippet.filter'].sudo().search( + [('id', '=', filter_id)] + request.website.website_domain() + ) + return dynamic_filter and dynamic_filter.render(template_key, limit, search_domain) or '' + + @http.route('/website/snippet/filter_templates', type='json', auth='public', website=True) + def get_dynamic_snippet_templates(self, filter_id=False): + # todo: if filter_id.model -> filter template + templates = request.env['ir.ui.view'].sudo().search_read( + [['key', 'ilike', '.dynamic_filter_template_'], ['type', '=', 'qweb']], ['key', 'name'] + ) + return templates + # ------------------------------------------------------ # Edit # ------------------------------------------------------ diff --git a/addons/website/data/website_data.xml b/addons/website/data/website_data.xml index b8462a17299..af1d1272ccb 100644 --- a/addons/website/data/website_data.xml +++ b/addons/website/data/website_data.xml @@ -698,6 +698,70 @@ <field name="type">url</field> <field name="url">/website/static/src/img/snippets_demo/s_popup.jpg</field> </record> + + <!-- Filter for Dynamic Snippet --> + <record id="dynamic_snippet_country_filter" model="ir.filters"> + <field name="name">Countries</field> + <field name="model_id">res.country</field> + <field name="user_id" eval="False" /> + </record> + <!-- Template for Dynamic Snippet --> + <template id="dynamic_filter_template_image_title_footer" name="Image Title Footer Card"> + <t t-foreach="records" t-as="record"> + <t t-set="fields" t-value="list(record['fields'].values())"/> + <t t-set="fieldsImg" t-value="list(record['image_fields'].values())"/> + <div class="card h-100" t-att-data-url="record['fields']['call_to_action_url']"> + <img t-if="len(fieldsImg) > 0" class="card-img-top p-3" loading="lazy" t-att-src="fieldsImg[0]"/> + <div class="card-body"> + <h5 t-if="len(fields) > 0" class="card-title"> + <t t-raw="fields[0]"/> + </h5> + <div t-if="len(fields) > 1" class="card-text"> + <t t-raw="fields[1]"/> + </div> + </div> + <div class="card-footer d-flex align-items-center"> + <div t-if="len(fields) > 2" class="card-text"> + <t t-raw="fields[2]"/> + </div> + <a t-if="call_to_action" class="btn btn-primary ml-auto" t-att-href="record['fields']['call_to_action_url']"> + <i class="fa fa-fw fa-eye"/> + </a> + </div> + </div> + </t> + </template> + <template id="dynamic_filter_template_header_image_footer_card" name="Header Image Footer Card"> + <t t-foreach="records" t-as="record"> + <t t-set="fields" t-value="list(record['fields'].values())"/> + <t t-set="fieldsImg" t-value="list(record['image_fields'].values())"/> + <div class="card h-100" t-att-data-url="record['fields']['call_to_action_url']"> + <h5 t-if="len(fields) > 0" class="card-header"> + <t t-raw="fields[0]"/> + </h5> + <div class="card-body"> + <img t-if="len(fieldsImg) > 0" class="card-img-top p-3" loading="lazy" t-att-src="fieldsImg[0]"/> + <div t-if="len(fields) > 1" class="card-text"> + <t t-raw="fields[1]"/> + </div> + </div> + <div class="card-footer d-flex align-items-center"> + <div t-if="len(fields) > 2" class="card-text"> + <t t-raw="fields[2]"/> + </div> + </div> + </div> + </t> + </template> + <!-- Dynamic Snippet Data Source --> + <record id="dynamic_snippet_data_source_country" model="website.snippet.filter"> + <field name="field_names">name,code,image,phone_code</field> + <field name="filter_id" ref="website.dynamic_snippet_country_filter"/> + <field name="limit" eval="16"/> + <field name="name">Countries</field> + <field name="website_id" ref="website.default_website"/> + </record> + </data> <data> <record id="group_multi_website" model="res.groups"> diff --git a/addons/website/models/__init__.py b/addons/website/models/__init__.py index b66ecb50a24..5df75fb65c0 100644 --- a/addons/website/models/__init__.py +++ b/addons/website/models/__init__.py @@ -23,3 +23,4 @@ from . import res_config_settings from . import res_lang from . import theme_models from . import website_visitor +from . import website_snippet_filter diff --git a/addons/website/models/ir_actions.py b/addons/website/models/ir_actions.py index e3fbf2a6761..24fbc937514 100644 --- a/addons/website/models/ir_actions.py +++ b/addons/website/models/ir_actions.py @@ -4,6 +4,7 @@ from werkzeug import urls from odoo import api, fields, models from odoo.http import request +from odoo.tools.json import scriptsafe as json_scriptsafe class ServerAction(models.Model): @@ -48,6 +49,7 @@ class ServerAction(models.Model): eval_context = super(ServerAction, self)._get_eval_context(action) if action.state == 'code': eval_context['request'] = request + eval_context['json'] = json_scriptsafe return eval_context @api.model diff --git a/addons/website/models/website_snippet_filter.py b/addons/website/models/website_snippet_filter.py new file mode 100644 index 00000000000..89fb3bd0817 --- /dev/null +++ b/addons/website/models/website_snippet_filter.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- + +from ast import literal_eval +from collections import OrderedDict +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError +from odoo.osv import expression + + +class WebsiteSnippetFilter(models.Model): + _name = 'website.snippet.filter' + _inherit = ['website.published.multi.mixin'] + _description = 'Website Snippet Filter' + _order = 'name ASC' + + name = fields.Char(required=True) + action_server_id = fields.Many2one('ir.actions.server', 'Server Action', ondelete='cascade') + field_names = fields.Char(help="A list of comma-separated field names", required=True) + filter_id = fields.Many2one('ir.filters', 'Filter', ondelete='cascade') + limit = fields.Integer(help='The limit is the maximum number of records retrieved', required=True) + website_id = fields.Many2one('website', string='Website', ondelete='cascade', required=True) + + @api.constrains('action_server_id', 'filter_id') + def _check_data_source_is_provided(self): + for record in self: + if bool(record.action_server_id) == bool(record.filter_id): + raise ValidationError(_("Either action_server_id or filter_id must be provided.")) + + @api.constrains('limit') + def _check_limit(self): + """Limit must be between 1 and 16.""" + for record in self: + if not 0 < record.limit <= 16: + raise ValidationError(_("The limit must be between 1 and 16.")) + + @api.constrains('field_names') + def _check_field_names(self): + for record in self: + for field_name in record.field_names.split(","): + if not field_name.strip(): + raise ValidationError(_("Empty field name in %r") % (record.field_names)) + + def render(self, template_key, limit, search_domain=[]): + """Renders the website dynamic snippet items""" + self.ensure_one() + assert '.dynamic_filter_template_' in template_key, _("You can only use template prefixed by dynamic_filter_template_ ") + + if self.env['website'].get_current_website() != self.website_id: + return '' + + records = self._prepare_values(limit, search_domain) + return self.env['ir.ui.view'].sudo().with_context(inherit_branding=False)._render_template(template_key, dict(records=records)) + + def _prepare_values(self, limit=None, search_domain=[]): + """Gets the data and returns it the right format for render.""" + self.ensure_one() + limit = limit and min(limit, self.limit) or self.limit + if self.filter_id: + filter_sudo = self.filter_id.sudo() + domain = filter_sudo._get_eval_domain() + if 'is_published' in self.env[filter_sudo.model_id]: + domain = expression.AND([domain, [('is_published', '=', True)]]) + if search_domain: + domain = expression.AND([domain, search_domain]), + + records = self.env[filter_sudo.model_id].search( + domain, + order=','.join(literal_eval(filter_sudo.sort)) or None, + limit=limit + ) + elif self.action_server_id: + records = self.action_server_id.with_context( + dynamic_filter=self, + limit=limit, + search_domain=search_domain, + ).sudo().run() + + return self._filter_records_to_dict_values(records) + + def _filter_records_to_dict_values(self, records): + """Extract the fields from the data source and put them into a dictionary of values + + [{ + 'fields': + OrderedDict([ + ('name', 'Afghanistan'), + ('code', 'AF'), + ]), + 'image_fields': + OrderedDict([ + ('image', '/web/image/res.country/3/image?unique=5d9b44e') + ]), + }, ... , ...] + + """ + + self.ensure_one() + values = [] + model = self.env[self.filter_id.model_id] if self.filter_id else self.env[self.action_server_id.model_id.model] + for record in records: + data = { + 'fields': OrderedDict({}), + 'image_fields': OrderedDict({}), + } + + for field_name in self.field_names.split(","): + field = model._fields.get(field_name) + data_type = 'image_fields' if field.type == 'binary' else 'fields' + if data_type == 'image_fields' and self.filter_id: + data[data_type][field_name] = self.env['website'].image_url(record, field_name) + else: + data[data_type][field_name] = record[field_name] + + data['fields']['call_to_action_url'] = 'website_url' in record and record['website_url'] + values.append(data) + return values diff --git a/addons/website/security/ir.model.access.csv b/addons/website/security/ir.model.access.csv index 163602f4d2d..e15f55b4c85 100644 --- a/addons/website/security/ir.model.access.csv +++ b/addons/website/security/ir.model.access.csv @@ -21,3 +21,5 @@ access_theme_ir_attachment,access_theme_ir_attachment,model_theme_ir_attachment, access_theme_website_menu,access_theme_website_menu,model_theme_website_menu,base.group_system,1,1,1,1 access_theme_website_page,access_theme_website_page,model_theme_website_page,base.group_system,1,1,1,1 access_website_robots,access.website.robots,model_website_robots,website.group_website_designer,1,1,1,0 +access_website_dynamic_filter_public,access_website_dynamic_filter,model_website_snippet_filter,,0,0,0,0 +access_website_dynamic_filter_system,access_website_dynamic_filter,model_website_snippet_filter,base.group_system,1,1,1,1 diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/000.js b/addons/website/static/src/snippets/s_dynamic_snippet/000.js new file mode 100644 index 00000000000..bb77874be01 --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet/000.js @@ -0,0 +1,245 @@ +odoo.define('website.s_dynamic_snippet', function (require) { +'use strict'; + +const core = require('web.core'); +const config = require('web.config'); +const publicWidget = require('web.public.widget'); + +const DynamicSnippet = publicWidget.Widget.extend({ + selector: '.s_dynamic_snippet', + xmlDependencies: ['/website/static/src/snippets/s_dynamic_snippet/000.xml'], + disabledInEditableMode: false, + + /** + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + /** + * The dynamic filter data source data formatted with the chosen template. + * Can be accessed when overriding the _render_content() function in order to generate + * a new renderedContent from the original data. + * + * @type {*|jQuery.fn.init|jQuery|HTMLElement} + */ + this.$data = $(); + this.renderedContent = ''; + this.isDesplayedAsMobile = config.device.isMobile; + this.uniqueId = _.uniqueId('s_dynamic_snippet_'); + this.template_key = 'website.s_dynamic_snippet.grid'; + }, + /** + * + * @override + */ + willStart: function () { + return this._super.apply(this, arguments).then( + () => Promise.all([ + this._fetchData(), + this._manageWarningMessageVisibility() + ]) + ); + }, + /** + * + * @override + */ + start: function () { + return this._super.apply(this, arguments) + .then(() => { + this._setupCallToAction(true); + this._setupSizeChangedManagement(true); + this._render(); + this._toggleVisibility(true); + }); + }, + /** + * + * @override + */ + destroy: function () { + this._toggleVisibility(false); + this._setupSizeChangedManagement(false); + this._setupCallToAction(false); + this._clearContent(); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * + * @private + */ + _clearContent: function() { + const $dynamicSnippetTemplate = this.$el.find('.dynamic_snippet_template'); + if ($dynamicSnippetTemplate) { + $dynamicSnippetTemplate.html(''); + } + }, + /** + * Method to be overridden in child components if additional configuration elements + * are required in order to fetch data. + * @private + */ + _isConfigComplete: function() { + return this.$el.get(0).dataset.filterId !== undefined && this.$el.get(0).dataset.templateKey !== undefined; + }, + /** + * Method to be overridden in child components in order to provide a search + * domain if needed. + * @private + */ + _getSearchDomain: function () { + return []; + }, + /** + * Fetches the data. + * @private + */ + _fetchData: function () { + if (this._isConfigComplete()) { + return this._rpc( + { + 'route': '/website/snippet/filters', + 'params': { + 'filter_id': parseInt(this.$el.get(0).dataset.filterId), + 'template_key': this.$el.get(0).dataset.templateKey, + 'limit': parseInt(this.$el.get(0).dataset.numberOfRecords), + 'search_domain': this._getSearchDomain() + }, + }) + .then( + (data) => { + this.$data = $(data).filter((index, node) => node.nodeType === 1); + } + ); + } else { + return new Promise((resolve) => { + this.$data = $(); + resolve(); + }); + } + }, + /** + * + * @private + */ + _manageWarningMessageVisibility: async function () { + this.$el.find('.missing_option_warning').toggleClass( + 'd-none', + this._isConfigComplete() + ); + }, + /** + * Method to be overridden in child components in order to prepare content + * before rendering. + * @private + */ + _prepareContent: function () { + if (this.$target[0].dataset.numberOfElements && this.$target[0].dataset.numberOfElementsSmallDevices) { + this.renderedContent = core.qweb.render( + this.template_key, + this._getQWebRenderOptions()); + } else { + this.renderedContent = ''; + } + }, + /** + * Method to be overridden in child components in order to prepare QWeb + * options. + * @private + */ + _getQWebRenderOptions: function () { + return { + chunkSize: parseInt( + config.device.isMobile + ? this.$target[0].dataset.numberOfElementsSmallDevices + : this.$target[0].dataset.numberOfElements + ), + data: this.$data.map((index, data) => data.outerHTML), + uniqueId: this.uniqueId + }; + }, + /** + * + * @private + */ + _render: function () { + if (this.$data.length > 0) { + this._prepareContent(); + } else { + this.renderedContent = ''; + } + this._renderContent(); + }, + /** + * + * @private + */ + _renderContent: function () { + this.$el.find('.dynamic_snippet_template').html(this.renderedContent); + }, + /** + * + * @param {Boolean} enable + * @private + */ + _setupCallToAction: function (enable) { + if (enable === true) { + this.$el.on('click.s_dynamic_snippet', '*[data-url]', function (ev) { + if (this.editableMode !== true) { + window.location = $(ev.currentTarget).attr('data-url'); + } + }.bind(this)); + } else { + this.$el.off('.s_dynamic_snippet'); + } + }, + /** + * + * @param {Boolean} enable + * @private + */ + _setupSizeChangedManagement: function (enable) { + if (enable === true) { + config.device.bus.on('size_changed', this, this._onSizeChanged); + } else { + config.device.bus.off('size_changed', this, this._onSizeChanged); + } + }, + /** + * + * @param visible + * @private + */ + _toggleVisibility: function (visible) { + this.$el.toggleClass('d-none', !visible); + }, + + //------------------------------------- ------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the size has reached a new bootstrap breakpoint. + * + * @private + * @param {number} size as Integer @see web.config.device.SIZES + */ + _onSizeChanged: function (size) { + if (this.isDesplayedAsMobile !== config.device.isMobile) { + this.isDesplayedAsMobile = config.device.isMobile; + this._render(); + } + }, +}); + +publicWidget.registry.dynamic_snippet = DynamicSnippet; + +return DynamicSnippet; + +}); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/000.scss b/addons/website/static/src/snippets/s_dynamic_snippet/000.scss new file mode 100644 index 00000000000..f463eecbe4b --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet/000.scss @@ -0,0 +1,16 @@ +body:not(.editor_enable) { + .s_dynamic { + *[data-url] { + cursor: pointer; + } + } +} + +.s_dynamic { + .card-img-top { + height: 12rem; + } + img { + object-fit: scale-down; + } +} diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/000.xml b/addons/website/static/src/snippets/s_dynamic_snippet/000.xml new file mode 100644 index 00000000000..105078e087f --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet/000.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="website.s_dynamic_snippet.grid"> + <!-- Content --> + <t t-set="colClass" t-value="'col-' + (12 / chunkSize).toString()"/> + <t t-set="rowIndexGenerator" t-value="Array.from(Array(Math.ceil(data.length/chunkSize)).keys())"/> + <t t-set="colIndexGenerator" t-value="Array.from(Array(chunkSize).keys())"/> + <t t-foreach="rowIndexGenerator" t-as="rowIndex"> + <div class="row my-4"> + <t t-foreach="colIndexGenerator" t-as="colIndex"> + <t t-if="(rowIndex * chunkSize + colIndex) < data.length"> + <div t-attf-class="#{colClass}"> + <t t-raw="data[rowIndex * chunkSize + colIndex]"/> + </div> + </t> + </t> + </div> + </t> + </t> +</templates> diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/options.js b/addons/website/static/src/snippets/s_dynamic_snippet/options.js new file mode 100644 index 00000000000..4c713703eb9 --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet/options.js @@ -0,0 +1,142 @@ +odoo.define('website.s_dynamic_snippet_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); + +const dynamicSnippetOptions = options.Class.extend({ + + /** + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.dynamicFilters = {}; + this.dynamicFilterTemplates = {}; + }, + /** + * + * @override + */ + onBuilt: function () { + this._setOptionsDefaultValues(); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * + * @see this.selectClass for parameters + */ + selectDataAttribute: function (previewMode, widgetValue, params) { + this._super.apply(this, arguments); + if (params.attributeName === 'filterId' && previewMode === false) { + this.$target.get(0).dataset.numberOfRecords = this.dynamicFilters[parseInt(widgetValue)].limit; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Fetches dynamic filters. + * @private + * @returns {Promise} + */ + _fetchDynamicFilters: function () { + return this._rpc({ + model: 'website.snippet.filter', + method: 'search_read', + kwargs: { + domain: [], + fields: ['id', 'name', 'limit'], + } + }); + }, + /** + * Fetch dynamic filters templates. + * @private + * @returns {Promise} + */ + _fetchDynamicFilterTemplates: function () { + return this._rpc({route: '/website/snippet/filter_templates'}); + }, + /** + * + * @override + * @private + */ + _renderCustomXML: function (uiFragment) { + return Promise.all([this._renderDynamicFiltersSelector(uiFragment), this._renderDynamicFilterTemplatesSelector(uiFragment)]); + }, + /** + * Renders the dynamic filter option selector content into the provided uiFragment. + * @param {HTMLElement} uiFragment + * @private + */ + _renderDynamicFiltersSelector: async function (uiFragment) { + const dynamicFilters = await this._fetchDynamicFilters(); + for (let index in dynamicFilters) { + this.dynamicFilters[dynamicFilters[index].id] = dynamicFilters[index]; + } + const filtersSelectorEl = uiFragment.querySelector('[data-name="filter_opt"]'); + return this._renderSelectUserValueWidgetButtons(filtersSelectorEl, this.dynamicFilters); + }, + /** + * Renders we-buttons into a SelectUserValueWidget element according to provided data. + * @param {HTMLElement} selectUserValueWidgetElement the SelectUserValueWidget buttons + * have to be created into. + * @param {JSON} data + * @private + */ + _renderSelectUserValueWidgetButtons: async function (selectUserValueWidgetElement, data) { + for (let id in data) { + const button = document.createElement('we-button'); + button.dataset.selectDataAttribute = id; + button.innerHTML = data[id].name; + selectUserValueWidgetElement.appendChild(button); + } + }, + /** + * Renders the template option selector content into the provided uiFragment. + * @param {HTMLElement} uiFragment + * @private + */ + _renderDynamicFilterTemplatesSelector: async function (uiFragment) { + const dynamicFilterTemplates = await this._fetchDynamicFilterTemplates(); + for (let index in dynamicFilterTemplates) { + this.dynamicFilterTemplates[dynamicFilterTemplates[index].key] = dynamicFilterTemplates[index]; + } + const templatesSelectorEl = uiFragment.querySelector('[data-name="template_opt"]'); + return this._renderSelectUserValueWidgetButtons(templatesSelectorEl, this.dynamicFilterTemplates); + }, + /** + * Sets default options values. + * Method to be overridden in child components in order to set additional + * options default values. + * @private + */ + _setOptionsDefaultValues: function () { + this._setOptionValue('numberOfElements', 4); + this._setOptionValue('numberOfElementsSmallDevices', 1); + }, + /** + * Sets the option value. + * @param optionName + * @param value + * @private + */ + _setOptionValue: function (optionName, value) { + if (this.$target.get(0).dataset[optionName] === undefined) { + this.$target.get(0).dataset[optionName] = value; + } + }, +}); + +options.registry.dynamic_snippet = dynamicSnippetOptions; + +return dynamicSnippetOptions; +}); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js new file mode 100644 index 00000000000..cf43100a8c9 --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js @@ -0,0 +1,46 @@ +odoo.define('website.s_dynamic_snippet_carousel', function (require) { +'use strict'; + +const config = require('web.config'); +const core = require('web.core'); +const publicWidget = require('web.public.widget'); +const DynamicSnippet = require('website.s_dynamic_snippet'); + +const DynamicSnippetCarousel = DynamicSnippet.extend({ + selector: '.s_dynamic_snippet_carousel', + xmlDependencies: (DynamicSnippet.prototype.xmlDependencies || []).concat( + ['/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml'] + ), + + /** + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.template_key = 'website.s_dynamic_snippet.carousel'; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Method to be overridden in child components in order to prepare QWeb + * options + * @private + */ + _getQWebRenderParams: function () { + return Object.assign( + this._super.apply(this, arguments), + { + interval : parseInt(this.$target[0].dataset.carouselInterval), + }, + ); + }, + +}); +publicWidget.registry.dynamic_snippet_carousel = DynamicSnippetCarousel; + +return DynamicSnippetCarousel; +}); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss new file mode 100644 index 00000000000..dee896d1468 --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss @@ -0,0 +1,13 @@ +.s_dynamic { + .carousel-control-prev, .carousel-control-next { + position: absolute; + text-shadow: 0px 0px 3px #343A40; + width: 4rem; + } + .carousel-control-prev{ + left: -4rem + } + .carousel-control-next{ + right: -4rem + } +} diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml new file mode 100644 index 00000000000..ca6cbf4d419 --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="website.s_dynamic_snippet.carousel"> + <div t-att-id="uniqueId" class="carousel slide" t-att-data-interval="interval"> + <!-- Content --> + <div class="carousel-inner row w-100 mx-auto" role="listbox"> + <t t-set="colClass" t-value="'col-' + (12 / chunkSize).toString()"/> + <t t-set="slideIndexGenerator" t-value="Array.from(Array(Math.ceil(data.length/chunkSize)).keys())"/> + <t t-set="itemIndexGenerator" t-value="Array.from(Array(chunkSize).keys())"/> + <t t-foreach="slideIndexGenerator" t-as="slideIndex"> + <div t-attf-class="carousel-item #{slideIndex_first ? 'active' : ''}"> + <div class="row"> + <t t-foreach="itemIndexGenerator" t-as="itemIndex"> + <t t-if="(slideIndex * chunkSize + itemIndex) < data.length"> + <div t-attf-class="#{colClass}"> + <t t-raw="data[slideIndex * chunkSize + itemIndex]"/> + </div> + </t> + </t> + </div> + </div> + </t> + </div> + <!-- Controls --> + <a t-attf-href="##{uniqueId}" class="carousel-control-prev" data-slide="prev" role="button" aria-label="Previous" title="Previous"> + <span class="fa fa-chevron-left fa-2x text-white"/> + <span class="sr-only">Previous</span> + </a> + <a t-attf-href="##{uniqueId}" class="carousel-control-next" data-slide="next" role="button" aria-label="Next" title="Next"> + <span class="fa fa-chevron-right fa-2x text-white"/> + <span class="sr-only">Next</span> + </a> + </div> + </t> +</templates> diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js new file mode 100644 index 00000000000..cb9a3cf566a --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js @@ -0,0 +1,28 @@ +odoo.define('website.s_dynamic_snippet_carousel_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); +const s_dynamic_snippet_options = require('website.s_dynamic_snippet_options'); + +const dynamicSnippetCarouselOptions = s_dynamic_snippet_options.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * + * @override + * @private + */ + _setOptionsDefaultValues: function () { + this._super.apply(this, arguments); + this._setOptionValue('carouselInterval', '5000'); + } + +}); + +options.registry.dynamic_snippet_carousel = dynamicSnippetCarouselOptions; + +return dynamicSnippetCarouselOptions; +}); diff --git a/addons/website/views/assets.xml b/addons/website/views/assets.xml index a2776696f85..b4c46e11a13 100644 --- a/addons/website/views/assets.xml +++ b/addons/website/views/assets.xml @@ -139,6 +139,8 @@ <script type="text/javascript" src="/website/static/src/snippets/s_timeline/options.js"/> <script type="text/javascript" src="/website/static/src/snippets/s_media_list/options.js"/> <script type="text/javascript" src="/website/static/src/snippets/s_google_map/options.js"/> + <script type="text/javascript" src="/website/static/src/snippets/s_dynamic_snippet/options.js"/> + <script type="text/javascript" src="/website/static/src/snippets/s_dynamic_snippet_carousel/options.js"/> <script type="text/javascript" src="/website/static/src/js/editor/wysiwyg_multizone.js"/> <script type="text/javascript" src="/website/static/src/js/editor/wysiwyg_multizone_translate.js"/> diff --git a/addons/website/views/snippets/s_dynamic_snippet.xml b/addons/website/views/snippets/s_dynamic_snippet.xml new file mode 100644 index 00000000000..683274bca84 --- /dev/null +++ b/addons/website/views/snippets/s_dynamic_snippet.xml @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="website.s_dynamic_snippet_template"> + <section t-attf-class="#{snippet_name} s_dynamic d-none pt24 pb24"> + <div class="container o_not_editable"> + <div class="css_non_editable_mode_hidden"> + <div class="missing_option_warning alert alert-info rounded-0 fade show d-print-none"> + Your Dynamic Snippet will be displayed here... This message is displayed because you did not provided both a filter and a template to use.<br/> + </div> + </div> + <div class="dynamic_snippet_template"/> + </div> + </section> + </template> + <template id="s_dynamic_snippet" name="Dynamic Snippet"> + <t t-call="website.s_dynamic_snippet_template"> + <t t-set="snippet_name" t-value="'s_dynamic_snippet'"/> + </t> + </template> + <template id="website.s_dynamic_snippet_options_template"> + <div t-attf-data-js="#{snippet_name}" t-attf-data-selector="#{snippet_selector}" data-no-preview="true"> + <we-select string="Filter" data-name="filter_opt" data-attribute-name="filterId"> + </we-select> + <we-select string="Template" data-name="template_opt" data-attribute-name="templateKey" data-no-preview="true"> + </we-select> + <we-select string="Fetched elements" data-name="number_of_records_opt" data-attribute-name="numberOfRecords" data-no-preview="true"> + <we-button data-select-data-attribute="1">1</we-button> + <we-button data-select-data-attribute="2">2</we-button> + <we-button data-select-data-attribute="3">3</we-button> + <we-button data-select-data-attribute="4">4</we-button> + <we-button data-select-data-attribute="6">6</we-button> + <we-button data-select-data-attribute="7">7</we-button> + <we-button data-select-data-attribute="8">8</we-button> + <we-button data-select-data-attribute="9">9</we-button> + <we-button data-select-data-attribute="10">10</we-button> + <we-button data-select-data-attribute="11">11</we-button> + <we-button data-select-data-attribute="12">12</we-button> + <we-button data-select-data-attribute="13">13</we-button> + <we-button data-select-data-attribute="14">14</we-button> + <we-button data-select-data-attribute="15">15</we-button> + <we-button data-select-data-attribute="16">16</we-button> + </we-select> + <we-title class="mt-2"><t t-esc="grouping_message"/></we-title> + <we-select string="⌙ Normal devices" data-name="number_of_elements_opt" data-attribute-name="numberOfElements" data-no-preview="true"><!--   --> + <we-button data-select-data-attribute="1">1</we-button> + <we-button data-select-data-attribute="2">2</we-button> + <we-button data-select-data-attribute="3">3</we-button> + <we-button data-select-data-attribute="4">4</we-button> + <we-button data-select-data-attribute="6">6</we-button> + </we-select> + <we-select string="⌙ Small devices" data-name="number_of_elements_small_devices_opt" data-attribute-name="numberOfElementsSmallDevices" data-no-preview="true"><!--   --> + <we-button data-select-data-attribute="1">1</we-button> + <we-button data-select-data-attribute="2">2</we-button> + <we-button data-select-data-attribute="3">3</we-button> + </we-select> + <t t-raw="0"/> + </div> + </template> + <template id="s_dynamic_snippet_options" inherit_id="website.snippet_options"> + <xpath expr="." position="inside"> + <t t-call="website.s_dynamic_snippet_options_template"> + <t t-set="snippet_name" t-value="'dynamic_snippet'"/> + <t t-set="snippet_selector" t-value="'.s_dynamic_snippet'"/> + <t t-set="grouping_message">Items per row</t> + </t> + </xpath> + </template> + <template id="assets_snippet_s_dynamic_snippet_css_000" inherit_id="website.assets_frontend"> + <xpath expr="//link[last()]" position="after"> + <link rel="stylesheet" type="text/scss" href="/website/static/src/snippets/s_dynamic_snippet/000.scss"/> + </xpath> + </template> + <template id="assets_snippet_s_dynamic_snippet_js_000" inherit_id="website.assets_frontend"> + <xpath expr="//script[last()]" position="after"> + <script type="text/javascript" src="/website/static/src/snippets/s_dynamic_snippet/000.js"/> + </xpath> + </template> +</odoo> diff --git a/addons/website/views/snippets/s_dynamic_snippet_carousel.xml b/addons/website/views/snippets/s_dynamic_snippet_carousel.xml new file mode 100644 index 00000000000..ea17154fc65 --- /dev/null +++ b/addons/website/views/snippets/s_dynamic_snippet_carousel.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="s_dynamic_snippet_carousel" name="Dynamic Carousel"> + <t t-call="website.s_dynamic_snippet_template"> + <t t-set="snippet_name" t-value="'s_dynamic_snippet_carousel'"/> + </t> + </template> + <template id="dynamic_snippet_carousel_options_template"> + <t t-call="website.s_dynamic_snippet_options_template"> + <t t-set="snippet_name" t-value="snippet_name"/> + <t t-set="snippet_selector" t-value="snippet_selector"/> + <t t-set="grouping_message">Items per slide</t> + <we-input string="Speed" + data-select-data-attribute="1s" data-name="speed_opt" data-attribute-name="carouselInterval" data-no-preview="true" + data-unit="s" data-save-unit="ms" data-step="0.1"/> + <t t-raw="0"/> + </t> + </template> + <template id="s_dynamic_snippet_carousel_options" inherit_id="website.snippet_options"> + <xpath expr="." position="inside"> + <t t-call="website.dynamic_snippet_carousel_options_template"> + <t t-set="snippet_name" t-value="'dynamic_snippet_carousel'"/> + <t t-set="snippet_selector" t-value="'.s_dynamic_snippet_carousel'"/> + </t> + </xpath> + </template> + <template id="assets_snippet_s_dynamic_snippet_carousel_css_000" inherit_id="website.assets_snippet_s_dynamic_snippet_css_000"> + <xpath expr="//link[last()]" position="after"> + <link rel="stylesheet" type="text/scss" href="/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss"/> + </xpath> + </template> + <template id="assets_snippet_s_dynamic_snippet_carousel_js_000" inherit_id="website.assets_snippet_s_dynamic_snippet_js_000"> + <xpath expr="//script[last()]" position="after"> + <script type="text/javascript" src="/website/static/src/snippets/s_dynamic_snippet_carousel/000.js"/> + </xpath> + </template> +</odoo> diff --git a/addons/website/views/snippets/snippets.xml b/addons/website/views/snippets/snippets.xml index f4d567762dd..36f2d030250 100644 --- a/addons/website/views/snippets/snippets.xml +++ b/addons/website/views/snippets/snippets.xml @@ -110,6 +110,9 @@ <div class="o_panel_body"> <t id="form_form_hook"/> <t t-snippet="website.s_google_map" t-thumbnail="/website/static/src/img/snippets_thumbs/s_google_map.svg"/> + <t t-snippet="website.s_dynamic_snippet" t-thumbnail="/website/static/src/img/snippets_thumbs/s_dynamic_snippet.svg"/> + <t t-snippet="website.s_dynamic_snippet_carousel" t-thumbnail="/website/static/src/img/snippets_thumbs/s_dynamic_carousel.svg"/> + <t id="sale_products_hook"/> <t id="sale_recently_viewed_product_hook"/> <t id="sale_product_search_section_hook"/> <t id="blog_posts_hook"/> diff --git a/addons/website_blog/data/website_blog_data.xml b/addons/website_blog/data/website_blog_data.xml index ae2bd0a14b1..bcf5f54d130 100644 --- a/addons/website_blog/data/website_blog_data.xml +++ b/addons/website_blog/data/website_blog_data.xml @@ -21,6 +21,7 @@ <field name="default" eval="True"/> <field name="description">Published Post</field> </record> + </data> <data> diff --git a/addons/website_blog/data/website_blog_demo.xml b/addons/website_blog/data/website_blog_demo.xml index f140b243c7d..2a17445d21c 100644 --- a/addons/website_blog/data/website_blog_demo.xml +++ b/addons/website_blog/data/website_blog_demo.xml @@ -74,6 +74,7 @@ <field name="is_published" eval="True"/> <field name="published_date" eval="(datetime.now()-relativedelta(days=2)).strftime('%Y-%m-%d %H:%M:%S')"/> <field name="tag_ids" eval="[(6, 0, [ref('blog_tag_2')])]"/> + <field name="visits">246</field> <field name="cover_properties">{"background-image": "url('/website_blog/static/src/img/cover_2.jpg')", "resize_class": "o_record_has_cover o_half_screen_height", "opacity": "0.4"}</field> <field name="content"><![CDATA[ <section class="container"> @@ -120,6 +121,7 @@ People who live on the island have never even seen this remarkable scenery unles <field name="blog_id" ref="blog_blog_1"/> <field name="author_id" ref="base.partner_demo"/> <field name="tag_ids" eval="[(6, 0, [ref('blog_tag_1')])]"/> + <field name="visits">467</field> <field name="is_published" eval="True"/> <field name="published_date" eval="(datetime.now()-relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/> <field name="cover_properties">{"background-image": "url('/website_blog/static/src/img/cover_3.jpg')", "resize_class": "o_record_has_cover o_half_screen_height", "opacity": "0.4"}</field> @@ -172,6 +174,7 @@ People who live on the island have never even seen this remarkable scenery unles <field name="blog_id" ref="blog_blog_2"/> <field name="author_id" ref="base.partner_admin"/> <field name="tag_ids" eval="[(6, 0, [ref('blog_tag_5')])]"/> + <field name="visits">453</field> <field name="is_published" eval="True"/> <field name="published_date" eval="(datetime.now()-relativedelta(days=6)).strftime('%Y-%m-%d %H:%M:%S')"/> <field name="cover_properties">{"background-image": "url('/website_blog/static/src/img/cover_4.jpg')", "resize_class": "o_record_has_cover o_half_screen_height", "text_align_class": "text-center", "opacity": "0.2"}</field> diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py index 2e8f8c0a690..c67d4036e7b 100644 --- a/addons/website_sale/__manifest__.py +++ b/addons/website_sale/__manifest__.py @@ -20,7 +20,8 @@ 'views/sale_order_views.xml', 'views/crm_team_views.xml', 'views/templates.xml', - 'views/snippets.xml', + 'views/snippets/snippets.xml', + 'views/snippets/s_dynamic_snippet_products.xml', 'views/snippets/s_products_searchbar.xml', 'views/res_config_settings_views.xml', 'views/digest_views.xml', diff --git a/addons/website_sale/data/data.xml b/addons/website_sale/data/data.xml index c2fddedad54..88291c2a23f 100644 --- a/addons/website_sale/data/data.xml +++ b/addons/website_sale/data/data.xml @@ -45,6 +45,55 @@ <field name="selectable" eval="True" /> <field name="website_id" eval="False"/> </record> + + <!-- Action Server for Dynamic Filter --> + <record id="dynamic_snippet_products_action" model="ir.actions.server"> + <field name="name">Products Dynamic Snippet</field> + <field name="model_id" ref="model_product_product"/> + <field name="state">code</field> + <field name="code"> +ProductProduct = model.env['product.product'] +FieldMonetary = model.env['ir.qweb.field.monetary'] + +website = request.website.get_current_website() +dynamic_filter = model.env.context.get('dynamic_filter') +limit = model.env.context.get('limit') +search_domain = model.env.context.get('search_domain') + +domain = [('website_published', '=', True)] + website.website_domain() + (search_domain or []) +products = ProductProduct.search(domain, limit=limit) +_ = products.mapped('name') + +monetary_options = { + 'display_currency': request.website.get_current_pricelist().currency_id, +} + +max_nb_chars = 100 +res_products = [] +for product in products: + res_product = product._read_format(['id', 'name', 'website_url', 'description_sale'])[0] + res_product.update(product._get_combination_info_variant()) + + if res_product['description_sale'] and len(res_product['description_sale']) > max_nb_chars: + res_product['description_sale'] = "%s ..." % res_product['description_sale'][:max_nb_chars] + res_product['list_price'] = FieldMonetary.value_to_html(res_product['price'], monetary_options) + for field_name in dynamic_filter.field_names.split(","): + field = ProductProduct._fields.get(field_name) + if field and field.type == 'binary': + res_product[field_name] = website.image_url(product, field_name) + res_products.append(res_product) + +response = res_products + </field> + </record> + <!-- Dynamic Filter --> + <record id="dynamic_filter_demo_products" model="website.snippet.filter"> + <field name="action_server_id" ref="website_sale.dynamic_snippet_products_action"/> + <field name="field_names">display_name,description_sale,image_512,list_price</field> + <field name="limit" eval="16"/> + <field name="name">Products</field> + <field name="website_id" ref="website.default_website"/> + </record> </data> <data> <function model="ir.model.fields" name="formbuilder_whitelist"> diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js new file mode 100644 index 00000000000..73b7d3abb02 --- /dev/null +++ b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js @@ -0,0 +1,41 @@ +odoo.define('website_sale.s_dynamic_snippet_products', function (require) { +'use strict'; + +const config = require('web.config'); +const core = require('web.core'); +const publicWidget = require('web.public.widget'); +const DynamicSnippetCarousel = require('website.s_dynamic_snippet_carousel'); + +const DynamicSnippetProducts = DynamicSnippetCarousel.extend({ + selector: '.s_dynamic_snippet_products', + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Method to be overridden in child components if additional configuration elements + * are required in order to fetch data. + * @override + * @private + */ + _isConfigComplete: function() { + return this._super.apply(this, arguments) && this.$el.get(0).dataset.productCategoryId !== undefined; + }, + /** + * Method to be overridden in child components in order to provide a search + * domain if needed. + * @override + * @private + */ + _getSearchDomain: function () { + const searchDomain = this._super.apply(this, arguments); + searchDomain.push(['public_categ_ids', 'child_of', parseInt(this.$el.get(0).dataset.productCategoryId)]); + return searchDomain; + }, + +}); +publicWidget.registry.dynamic_snippet_products = DynamicSnippetProducts; + +return DynamicSnippetProducts; +}); diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.xml b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.xml new file mode 100644 index 00000000000..f45bf3c51bb --- /dev/null +++ b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="website_sale.s_dynamic_snippet.products" inherit_id="website.s_dynamic_snippet.carousel"> + <xpath expr="//div" position="attributes"> + <attribute name="data-filter-id" add=""/> + </xpath> + </t> +</templates> diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js new file mode 100644 index 00000000000..7acf98a866c --- /dev/null +++ b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js @@ -0,0 +1,93 @@ +odoo.define('website_sale.s_dynamic_snippet_products_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); +const s_dynamic_snippet_carousel_options = require('website.s_dynamic_snippet_carousel_options'); + +const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend({ + + /** + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.productCategories = {}; + }, + /** + * + * @override + */ + onBuilt: function () { + this._super.apply(this, arguments); + this._rpc({ + model: 'ir.model.data', + method: 'search_read', + kwargs: { + domain: [['module', '=', 'website_sale'], ['model', '=', 'website.snippet.filter']], + fields: ['id', 'res_id'], + } + }).then((data) => { + this.$target.get(0).dataset.filterId = data[0].res_id; + this.$target.get(0).dataset.numberOfRecords = this.dynamicFilters[data[0].res_id].limit; + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * + * @override + * @private + */ + _computeWidgetVisibility: function (widgetName, params) { + if (widgetName === 'filter_opt') { + return false; + } + return this._super.apply(this, arguments); + }, + /** + * Fetches product categories. + * @private + * @returns {Promise} + */ + _fetchProductCategories: function () { + return this._rpc({ + model: 'product.public.category', + method: 'search_read', + kwargs: { + domain: [], + fields: ['id', 'name'], + } + }); + }, + /** + * + * @override + * @private + */ + _renderCustomXML: function (uiFragment) { + return Promise.all([this._super.apply(this, arguments), this._renderProductCategorySelector(uiFragment)]); + }, + /** + * Renders the product categories option selector content into the provided uiFragment. + * @private + * @param {HTMLElement} uiFragment + */ + _renderProductCategorySelector: async function (uiFragment) { + const productCategories = await this._fetchProductCategories(); + for (let index in productCategories) { + this.productCategories[productCategories[index].id] = productCategories[index]; + } + const productCategoriesSelectorEl = uiFragment.querySelector('[data-name="product_category_opt"]'); + return this._renderSelectUserValueWidgetButtons(productCategoriesSelectorEl, this.productCategories); + }, + +}); + +options.registry.dynamic_snippet_products = dynamicSnippetProductsOptions; + +return dynamicSnippetProductsOptions; +}); diff --git a/addons/website_sale/views/snippets/s_dynamic_snippet_products.xml b/addons/website_sale/views/snippets/s_dynamic_snippet_products.xml new file mode 100644 index 00000000000..9b2c18a9a69 --- /dev/null +++ b/addons/website_sale/views/snippets/s_dynamic_snippet_products.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="s_dynamic_snippet_products" name="Dynamic Products"> + <t t-call="website.s_dynamic_snippet_template"> + <t t-set="snippet_name" t-value="'s_dynamic_snippet_products'"/> + </t> + </template> + <template id="s_dynamic_snippet_products_options" inherit_id="website.snippet_options"> + <xpath expr="." position="inside"> + <t t-call="website.dynamic_snippet_carousel_options_template"> + <t t-set="snippet_name" t-value="'dynamic_snippet_products'"/> + <t t-set="snippet_selector" t-value="'.s_dynamic_snippet_products'"/> + <we-select string="Product Category" data-name="product_category_opt" data-attribute-name="productCategoryId" data-no-preview="true"> + </we-select> + </t> + </xpath> + </template> + <template id="assets_snippet_s_dynamic_snippet_products_js_000" inherit_id="website.assets_snippet_s_dynamic_snippet_carousel_js_000"> + <xpath expr="//script[last()]" position="after"> + <script type="text/javascript" src="/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js"/> + </xpath> + </template> +</odoo> diff --git a/addons/website_sale/views/snippets.xml b/addons/website_sale/views/snippets/snippets.xml index 9b454b92371..f370856490f 100644 --- a/addons/website_sale/views/snippets.xml +++ b/addons/website_sale/views/snippets/snippets.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> -<template id="s_products_recently_viewed" name="Products"> +<template id="s_products_recently_viewed" name="Viewed Products"> <section class="s_wsale_products_recently_viewed d-none pt24 pb24" style="min-height: 400px;"> <div class="container"> <div class="alert alert-info alert-dismissible rounded-0 fade show d-print-none css_non_editable_mode_hidden o_not_editable"> @@ -17,6 +17,9 @@ </template> <template id="snippets" inherit_id="website.snippets" name="e-commerce snippets"> + <xpath expr="//t[@id='sale_products_hook']" position="replace"> + <t t-snippet="website_sale.s_dynamic_snippet_products" t-thumbnail="/website_sale/static/src/img/snippets_thumbs/s_products_recently_viewed.svg"/> + </xpath> <xpath expr="//t[@id='sale_recently_viewed_product_hook']" position="replace"> <t t-snippet="website_sale.s_products_recently_viewed" t-thumbnail="/website_sale/static/src/img/snippets_thumbs/s_products_recently_viewed.svg"/> </xpath> diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 0749dceab81..546a304f160 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -39,6 +39,7 @@ <template id="assets_wysiwyg" inherit_id="website.assets_wysiwyg" name="Shop Wysiwyg Editor"> <xpath expr="//link[last()]" position="after"> <link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale.editor.scss"/> + <script type="text/javascript" src="/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js"/> </xpath> </template> |
