summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/website/__manifest__.py2
-rw-r--r--addons/website/controllers/main.py15
-rw-r--r--addons/website/data/website_data.xml64
-rw-r--r--addons/website/models/__init__.py1
-rw-r--r--addons/website/models/ir_actions.py2
-rw-r--r--addons/website/models/website_snippet_filter.py116
-rw-r--r--addons/website/security/ir.model.access.csv2
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet/000.js245
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet/000.scss16
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet/000.xml20
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet/options.js142
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js46
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss13
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml35
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js28
-rw-r--r--addons/website/views/assets.xml2
-rw-r--r--addons/website/views/snippets/s_dynamic_snippet.xml78
-rw-r--r--addons/website/views/snippets/s_dynamic_snippet_carousel.xml37
-rw-r--r--addons/website/views/snippets/snippets.xml3
-rw-r--r--addons/website_blog/data/website_blog_data.xml1
-rw-r--r--addons/website_blog/data/website_blog_demo.xml3
-rw-r--r--addons/website_sale/__manifest__.py3
-rw-r--r--addons/website_sale/data/data.xml49
-rw-r--r--addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js41
-rw-r--r--addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.xml8
-rw-r--r--addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js93
-rw-r--r--addons/website_sale/views/snippets/s_dynamic_snippet_products.xml23
-rw-r--r--addons/website_sale/views/snippets/snippets.xml (renamed from addons/website_sale/views/snippets.xml)5
-rw-r--r--addons/website_sale/views/templates.xml1
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) &lt; 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) &lt; 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"><!-- &emsp; -->
+ <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"><!-- &emsp; -->
+ <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>