[IMP] website, website_sale: add dynamic snippets

* Implement new snippets that allow the user to choose a filter
  and a template.
  Three snippets have been created:
  - Dynamic Snippet: Displays the data in a grid format
  - Dynamic Carousel: Displays the date in a carousel
  - Dynamic Products: Let the user pick a product category and
                      displays the products in a carousel

task-2276740
PR #53175

Signed-off-by: Jérémy Kersten (jke) <jke@openerp.com>
This commit is contained in:
Laurent Stukkens (LTU)
2020-08-18 17:40:25 +00:00
parent 8b6985eeed
commit 0e7640b5f2
29 changed files with 1092 additions and 2 deletions
+2
View File
@@ -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',
+15
View File
@@ -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
# ------------------------------------------------------
+64
View File
@@ -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">
+1
View File
@@ -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
+2
View File
@@ -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
@@ -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
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
21 access_theme_website_menu access_theme_website_menu model_theme_website_menu base.group_system 1 1 1 1
22 access_theme_website_page access_theme_website_page model_theme_website_page base.group_system 1 1 1 1
23 access_website_robots access.website.robots model_website_robots website.group_website_designer 1 1 1 0
24 access_website_dynamic_filter_public access_website_dynamic_filter model_website_snippet_filter 0 0 0 0
25 access_website_dynamic_filter_system access_website_dynamic_filter model_website_snippet_filter base.group_system 1 1 1 1
@@ -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;
});
@@ -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;
}
}
@@ -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>
@@ -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;
});
@@ -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;
});
@@ -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
}
}
@@ -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>
@@ -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;
});
+2
View File
@@ -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"/>
@@ -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>
@@ -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>
@@ -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"/>
@@ -21,6 +21,7 @@
<field name="default" eval="True"/>
<field name="description">Published Post</field>
</record>
</data>
<data>
@@ -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>
+2 -1
View File
@@ -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',
+49
View File
@@ -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">
@@ -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;
});
@@ -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>
@@ -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;
});
@@ -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>
@@ -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>
+1
View File
@@ -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>