[IMP] website, *: introduce inputs in the left panel UI

* web_editor, website_sale

Part of https://github.com/odoo/odoo/pull/38959
task-2066614
This commit is contained in:
qsm-odoo
2019-11-18 09:56:08 +00:00
parent c47527b794
commit bf5b4b6933
8 changed files with 461 additions and 107 deletions
@@ -353,6 +353,12 @@ var SnippetEditor = Widget.extend({
el.parentNode.insertBefore(checkboxEl, el);
el.parentNode.removeChild(el);
});
uiEl.querySelectorAll('we-input').forEach(el => {
const infos = this._extraInfoFromDescriptionElement(el);
const inputEl = options.Class.prototype.buildInputElement(infos.title, infos.options);
el.parentNode.insertBefore(inputEl, el);
el.parentNode.removeChild(el);
});
return $optionSection;
},
@@ -10,6 +10,50 @@ var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
var qweb = core.qweb;
var _t = core._t;
/**
* window.getComputedStyle cannot work properly with CSS shortcuts (like
* 'border-width' which is a shortcut for the top + right + bottom + left border
* widths. If an options wants to customize such a shortcut, it should be listed
* here with the non-shortcuts property it stands for, in order.
*
* @type {Object<string[]>}
*/
const CSS_SHORTHANDS = {
'border-width': ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'],
'border-radius': ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius'],
};
/**
* Key-value mapping to list converters from an unit A to an unit B.
* - The key is a string in the format '$1-$2' where $1 is the CSS symbol of
* unit A and $2 is the CSS symbol of unit B.
* - The value is a function that converts the received value (expressed in
* unit A) to another value expressed in unit B. Two other parameters is
* received: the css property on which the unit applies and the jQuery element
* on which that css property may change.
*/
const CSS_UNITS_CONVERSION = {
's-ms': () => 1000,
'ms-s': () => 0.001,
'rem-px': () => _computePxByRem(),
'px-rem': () => _computePxByRem(true),
};
/**
* Computes the number of "px" needed to make a "rem" unit. Subsequent calls
* returns the cached computed value.
*
* @param {boolean} [toRem=false]
* @returns {float} - number of px by rem if 'toRem' is false
* - the inverse otherwise
*/
function _computePxByRem(toRem) {
if (_computePxByRem.PX_BY_REM === undefined) {
const htmlStyle = window.getComputedStyle(document.documentElement);
_computePxByRem.PX_BY_REM = parseFloat(htmlStyle['font-size']);
}
return toRem ? (1 / _computePxByRem.PX_BY_REM) : _computePxByRem.PX_BY_REM;
}
/**
* Handles a set of options for one snippet. The registry returned by this
* module contains the names of the specialized SnippetOption which can be
@@ -17,12 +61,14 @@ var _t = core._t;
*/
var SnippetOption = Widget.extend({
events: {
'mouseenter': '_onLinkEnter',
'mouseenter we-button': '_onLinkEnter',
'click': '_onLinkClick',
'click we-button': '_onLinkClick',
'mouseleave': '_onMouseleave',
'mouseleave we-button': '_onMouseleave',
'mouseenter we-button': '_onOptionPreview',
'click we-button': '_onOptionSelection',
'mouseleave we-button': '_onOptionCancel',
'input we-input input': '_onOptionPreview',
'blur we-input input': '_onOptionInputBlur',
'click we-input': '_onOptionInputClick',
'keydown we-input input': '_onOptionInputKeydown',
},
/**
@@ -119,6 +165,108 @@ var SnippetOption = Widget.extend({
// Options
//--------------------------------------------------------------------------
/**
* Default option method which allows to handle an user input and set the
* appropriate data attribute on the associated snippet.
*
* @param {boolean} previewMode - never 'reset' for this method (@see this.selectClass)
* @param {Object} dataName - the data name to customize
* @param {jQuery} $opt - the related DOMElement option
*/
setDataAttribute: function (previewMode, dataName, $opt) {
let hasUserValue = false;
const weInput = $opt[0];
const inputValue = weInput.querySelector('input').value.trim();
const inputUnit = weInput.dataset.unit;
const saveUnit = weInput.dataset.saveUnit;
const defaultValue = weInput.dataset.defaultValue;
let value;
if (inputValue) {
const numValue = parseFloat(inputValue);
if (!isNaN(numValue)) {
hasUserValue = true;
value = this._convertNumericToUnit(numValue, inputUnit, saveUnit);
}
}
if (value === undefined) {
value = this._convertValueToUnit(defaultValue, saveUnit);
}
this.$target[0].dataset[dataName] = isNaN(value) ? defaultValue : parseFloat(value.toFixed(3));
var extraClass = weInput.dataset.extraClass;
if (extraClass) {
this.$target.toggleClass(extraClass, hasUserValue);
}
},
/**
* Default option method which allows to handle an user input and set the
* appropriate css style on the associated snippet.
*
* @param {boolean} previewMode - never 'reset' for this method (@see this.selectClass)
* @param {Object} mainCssProp - the cssProp to customize
* @param {jQuery} $opt - the related DOMElement option
*/
setStyle: function (previewMode, mainCssProp, $opt) {
let hasUserValue = false;
const cssProps = CSS_SHORTHANDS[mainCssProp] || [mainCssProp];
// Join all inputs controlling the same css property and split user
// input into sub-properties (note this code handles the two at the same
// time but should normally not be combined).
const $weInputs = this.$el.find(`[data-set-style=${mainCssProp}]`);
const subValuesByInput = _.map($weInputs, weInput => {
const inputValue = weInput.querySelector('input').value.trim();
const inputUnit = weInput.dataset.unit;
const saveUnit = weInput.dataset.saveUnit;
const defaultValue = weInput.dataset.defaultValue;
let convertedDefaultValue = this._convertValueToUnit(defaultValue, saveUnit, mainCssProp);
convertedDefaultValue = isNaN(convertedDefaultValue) ? defaultValue : (parseFloat(convertedDefaultValue.toFixed(3)) + saveUnit);
const values = inputValue.split(/\s+/g).map(v => {
const numValue = parseFloat(v);
if (isNaN(numValue)) {
return convertedDefaultValue;
} else {
hasUserValue = true;
const value = this._convertNumericToUnit(numValue, inputUnit, saveUnit, mainCssProp);
return parseFloat(value.toFixed(3)) + saveUnit;
}
});
while (values.length < cssProps.length) {
switch (values.length) {
case 1:
case 2:
values.push(values[0]);
break;
case 3:
values.push(values[1]);
break;
default:
values.push(values[values.length - 1]);
}
}
return values;
});
for (const cssProp of cssProps) {
// Always reset the inline style first to not put inline style on an
// element which already have this style through css stylesheets.
this.$target[0].style.setProperty(cssProp, '');
}
const styles = window.getComputedStyle(this.$target[0]);
cssProps.forEach((cssProp, i) => {
const cssValue = subValuesByInput.map(inputSubValues => inputSubValues[i]).join(' ');
if (styles[cssProp] !== cssValue) {
this.$target[0].style.setProperty(cssProp, cssValue, 'important');
}
});
var extraClass = $opt[0].dataset.extraClass;
if (extraClass) {
this.$target.toggleClass(extraClass, hasUserValue);
}
},
/**
* Default option method which allows to select one and only one class in
* the option classes set and set it on the associated snippet. The common
@@ -156,11 +304,11 @@ var SnippetOption = Widget.extend({
* @see this.selectClass
*/
toggleClass: function (previewMode, value, $opt) {
var $lis = this.$el.find('[data-toggle-class]');
var classes = $lis.map(function () {
const $opts = this.$el.find('[data-toggle-class]').not('[data-set-style]');
const classes = $opts.map(function () {
return $(this).data('toggleClass');
}).get().join(' ');
var activeClasses = $lis.filter('.active, :has(.active)').map(function () {
const activeClasses = $opts.filter('.active, :has(.active)').map(function () {
return $(this).data('toggleClass');
}).get().join(' ');
@@ -214,6 +362,48 @@ var SnippetOption = Widget.extend({
// Private
//--------------------------------------------------------------------------
/**
* Converts the given (value + unit) string to a numeric value expressed in
* the given css unit.
*
* e.g. fct('400ms', 's') -> 0.4
*
* @param {string} value
* @param {string} unitTo
* @param {string} [cssProp]
* @returns {number}
*/
_convertValueToUnit: function (value, unitTo, cssProp) {
const m = value.trim().match(/^([0-9.]+)(\w*)$/);
if (!m) {
return NaN;
}
const numValue = parseFloat(m[1]);
const valueUnit = m[2];
return this._convertNumericToUnit(numValue, valueUnit, unitTo, cssProp);
},
/**
* Converts the given numeric value expressed in the given css unit into
* the corresponding numeric value expressed in the other given css unit.
*
* e.g. fct(400, 'ms', 's') -> 0.4
*
* @param {number} value
* @param {string} unitFrom
* @param {string} unitTo
* @param {string} [cssProp]
* @returns {number}
*/
_convertNumericToUnit: function (value, unitFrom, unitTo, cssProp) {
if (Math.abs(value) < Number.EPSILON || unitFrom === unitTo) {
return value;
}
const converter = CSS_UNITS_CONVERSION[`${unitFrom}-${unitTo}`];
if (converter === undefined) {
throw new Error(`Cannot convert ${unitFrom} into ${unitTo} !`);
}
return value * converter(cssProp, this.$target);
},
/**
* Reactivate the options that were activated before previews.
*/
@@ -266,13 +456,16 @@ var SnippetOption = Widget.extend({
var $el = $(data[0]);
var methods = data[1];
const isInput = $el.is('we-input');
Object.keys(methods).forEach(methodName => {
if (this[methodName]) {
if (previewMode === true) {
this.__methodNames.push(methodName);
}
this[methodName](previewMode, methods[methodName], $el);
if (!this[methodName]) {
return;
}
if (previewMode === true && !isInput) {
this.__methodNames.push(methodName);
}
this[methodName](previewMode, methods[methodName], $el);
});
});
this.__methodNames = _.uniq(this.__methodNames);
@@ -292,6 +485,9 @@ var SnippetOption = Widget.extend({
*/
_setActive: function () {
var self = this;
// --- TOGGLE CLASS ---
this.$el.find('[data-toggle-class]')
.removeClass('active')
.filter(function () {
@@ -300,6 +496,8 @@ var SnippetOption = Widget.extend({
})
.addClass('active');
// --- SELECT CLASS ---
// Get submenus which are not inside submenus
var $submenus = this.$el.find('we-collapse-area, we-select')
.not('we-collapse-area *, we-select *');
@@ -330,6 +528,92 @@ var SnippetOption = Widget.extend({
.last()
.addClass('active');
}
// --- SET DATA ATTRIBUTE --- (note: important to be done last because of active removal)
this.el.querySelectorAll('[data-set-data-attribute]').forEach(el => {
el.classList.remove('active');
const inputEl = el.querySelector('input');
if (inputEl === document.activeElement) {
return;
}
const inputUnit = el.dataset.unit;
const saveUnit = el.dataset.saveUnit;
const defaultValue = el.dataset.defaultValue;
const convertedDefaultValue = this._convertValueToUnit(defaultValue, inputUnit);
let value = this.$target[0].dataset[el.dataset.setDataAttribute];
let numValue = parseFloat(value);
if (!isNaN(numValue)) {
numValue = this._convertNumericToUnit(numValue, saveUnit, inputUnit);
}
if (isNaN(numValue) || Math.abs(numValue - convertedDefaultValue) < Number.EPSILON) {
value = '';
} else {
value = parseFloat(numValue.toFixed(3));
}
inputEl.value = value;
});
// --- SET STYLE --- (note: important to be done last because of active removal)
let styles;
const seenSetStyles = [];
this.el.querySelectorAll('[data-set-style]').forEach(el => {
const mainCssProp = el.dataset.setStyle;
if (seenSetStyles.includes(mainCssProp)) {
return;
}
seenSetStyles.push(mainCssProp);
const $els = this.$el.find(`[data-set-style="${mainCssProp}"]`);
$els.removeClass('active');
const $inputs = $els.find('> input');
if (_.any($inputs, input => input === document.activeElement)) {
return;
}
styles = styles || window.getComputedStyle(this.$target[0]);
const cssProps = CSS_SHORTHANDS[mainCssProp] || [mainCssProp];
const valuesByProp = cssProps.map(cssProp => {
const cssValue = styles[cssProp];
if (!cssValue) {
return [];
}
return cssValue.split(/\s+/).map((v, i) => {
const inputUnit = $els[i].dataset.unit;
const numValue = this._convertValueToUnit(v, inputUnit, mainCssProp);
return isNaN(numValue) ? v : numValue;
});
});
_.each($els, (el, i) => {
let values = valuesByProp.map(propValues => propValues[i]);
if (values.length === 4 && Math.abs(values[3] - values[1]) < Number.EPSILON) {
values.pop();
}
if (values.length === 3 && Math.abs(values[2] - values[0]) < Number.EPSILON) {
values.pop();
}
if (values.length === 2 && Math.abs(values[1] - values[0]) < Number.EPSILON) {
values.pop();
}
const inputUnit = el.dataset.unit;
const defaultValue = el.dataset.defaultValue;
const convertedDefaultValue = this._convertValueToUnit(defaultValue, inputUnit, mainCssProp);
if (values.length === 1 && Math.abs(values[0] - convertedDefaultValue) < Number.EPSILON) {
values.pop();
}
values = values.filter(v => isFinite(v));
values = values.map(v => parseFloat(v.toFixed(3)));
$inputs[i].value = (values.length ? values.join(' ') : '');
});
});
},
/**
* @private
@@ -351,14 +635,71 @@ var SnippetOption = Widget.extend({
//--------------------------------------------------------------------------
/**
* Called when a option link is entered -> activates the related option in
* preview mode.
* @private
* @param {Event} ev
*/
_onOptionInputBlur: function (ev) {
// Sometimes, an input is focusout for internal reason (like an undo
// recording) then focused again manually in the same JS stack
// execution. In that case, the blur should not trigger an option
// selection as the user did not leave the input. We thus defer the blur
// handling to then check that the target is indeed still blurred before
// executing the actual option selection.
setTimeout(() => {
if (ev.currentTarget === document.activeElement) {
return;
}
this._onOptionSelection(ev);
});
},
/**
* @private
* @param {Event} ev
*/
_onOptionInputClick: function (ev) {
const inputEl = ev.currentTarget.querySelector('input');
if (inputEl) {
inputEl.select();
}
},
/**
* @private
* @param {Event} ev
*/
_onOptionInputKeydown: function (ev) {
const input = ev.currentTarget;
let value = parseFloat(input.value || input.placeholder);
if (isNaN(value)) {
return;
}
let step = parseFloat(input.parentNode.dataset.step);
if (isNaN(step)) {
step = 1.0;
}
switch (ev.which) {
case $.ui.keyCode.UP:
value += step;
break;
case $.ui.keyCode.DOWN:
value -= step;
break;
default:
return;
}
input.value = parseFloat(value.toFixed(3));
$(input).trigger('input');
},
/**
* Called when a option link is entered or an option input content is being
* modified -> activates the related option in preview mode.
*
* @private
* @param {Event} ev
*/
_onLinkEnter: function (ev) {
var $opt = $(ev.target).closest('we-button');
_onOptionPreview: function (ev) {
var $opt = $(ev.target).closest('we-button, we-input');
if (!$opt.length) {
return;
}
@@ -371,13 +712,14 @@ var SnippetOption = Widget.extend({
this.$target.trigger('snippet-option-preview', [this]);
},
/**
* Called when an option link is clicked -> activates the related option.
* Called when an option link is clicked or an option input content is
* validated -> activates the related option.
*
* @private
* @param {Event} ev
*/
_onLinkClick: function (ev) {
var $opt = $(ev.target).closest('we-button');
_onOptionSelection: function (ev) {
var $opt = $(ev.target).closest('we-button, we-input');
if (ev.isDefaultPrevented() || !$opt.length || !$opt.is(':hasData')) {
return;
}
@@ -393,7 +735,7 @@ var SnippetOption = Widget.extend({
*
* @private
*/
_onMouseleave: function () {
_onOptionCancel: function () {
if (this.__click) {
return;
}
@@ -472,6 +814,42 @@ var SnippetOption = Widget.extend({
return groupEl;
},
/**
* Build the correct DOM for a we-input element.
*
* @static
* @param {string} [title]
* @param {Object} [options] - @see this.buildElement
*/
buildInputElement: function (title, options) {
const inputWrapperEl = SnippetOption.prototype.buildElement('we-input', title, options);
var unit = inputWrapperEl.dataset.unit;
if (unit === undefined) {
unit = 'px';
}
inputWrapperEl.dataset.unit = unit;
if (inputWrapperEl.dataset.saveUnit === undefined) {
inputWrapperEl.dataset.saveUnit = unit;
}
var defaultValue = inputWrapperEl.dataset.defaultValue;
if (defaultValue === undefined) {
defaultValue = ('0' + unit);
}
inputWrapperEl.dataset.defaultValue = defaultValue;
var inputEl = document.createElement('input');
inputEl.setAttribute('type', 'text');
inputEl.setAttribute('placeholder', defaultValue.replace(unit, ''));
inputWrapperEl.appendChild(inputEl);
var unitEl = document.createElement('span');
unitEl.textContent = unit;
inputWrapperEl.appendChild(unitEl);
return inputWrapperEl;
},
/**
* Build the correct DOM for a we-select element.
*
@@ -719,7 +1097,7 @@ registry.sizing = SnippetOption.extend({
});
_.each(this.$overlay.find(".o_handle.n, .o_handle.s"), function (handle) {
var $handle = $(handle);
var direction = $handle.hasClass('n') ? 'top': 'bottom';
var direction = $handle.hasClass('n') ? 'top' : 'bottom';
$handle.height(self.$target.css('padding-' + direction));
});
this.$target.trigger('content_changed');
@@ -1546,5 +1924,7 @@ registry.many2one = SnippetOption.extend({
return {
Class: SnippetOption,
registry: registry,
CSS_SHORTHANDS: CSS_SHORTHANDS,
CSS_UNITS_CONVERSION: CSS_UNITS_CONVERSION,
};
});
@@ -369,6 +369,31 @@ body.editor_enable.editor_has_snippets {
}
}
we-input {
display: flex;
align-items: center;
> we-title {
min-width: 60%;
margin-right: 5%;
}
> input {
flex: 1 1 auto;
width: 0;
min-width: 3em;
margin: 0 2px 0 0;
padding: 0 2px;
border: none;
border-radius: 0;
background-color: var(--o-we-bg-color-dark);
color: var(--o-we-color);
text-align: right;
}
> span {
flex: 0 0 auto;
}
}
.dropdown-menu {
// FIXME temporary fix for m2o option for example
position: static !important;
@@ -327,29 +327,11 @@ options.registry.carousel = options.Class.extend({
this.$target.carousel('prev');
}
},
/**
* Changes the interval for autoplay.
*
* @see this.selectClass for parameters
*/
interval: function (previewMode, value) {
this.$target.attr('data-interval', value);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_setActive: function () {
this._super.apply(this, arguments);
this.$el.find('[data-interval]')
.removeClass('active')
.filter(`[data-interval=${this.$target.attr('data-interval')}]`)
.addClass('active');
},
/**
* Activates current slide for edition and vertical padding. Padding on the
* carousel itself would mean the slides' backgrounds don't cover the padding.
@@ -1864,7 +1846,7 @@ options.registry.SnippetMove = options.Class.extend({
// TODO this is kinda hack but not worth a complex system while no
// other use case is implemented.
$buttons.on('click', this._onLinkClick.bind(this));
$buttons.on('click', this._onOptionSelection.bind(this));
return this._super(...arguments);
},
+3 -10
View File
@@ -1131,14 +1131,7 @@
<we-divider/>
<we-button data-select-class="s_carousel_default">Default</we-button>
</we-select>
<we-select string="Speed">
<we-button data-interval="1000">1s</we-button>
<we-button data-interval="2000">2s</we-button>
<we-button data-interval="3000">3s</we-button>
<we-button data-interval="5000">5s</we-button>
<we-button data-interval="10000">10s</we-button>
<we-button data-interval="0">Disable autoplay</we-button>
</we-select>
<we-input string="Speed" data-set-data-attribute="interval" data-unit="s" data-save-unit="ms" data-step="0.1"/>
</div>
<div data-js="navTabs" data-selector="section .row > div" data-target=".nav-tabs">
@@ -1303,8 +1296,8 @@
<div data-selector="section .row > div"
data-exclude=".s_col_no_bgcolor, .s_col_no_bgcolor.row > div, .o_gallery .row > div">
<we-checkbox string="Border" data-toggle-class="border"/>
<we-checkbox string="Rounded" data-toggle-class="rounded"/>
<we-input string="Border" data-set-style="border-width" data-extra-class="border"/>
<we-input string="Rounded" data-set-style="border-radius" data-extra-class="rounded"/>
<we-checkbox string="Shadow" data-toggle-class="shadow"/>
</div>
@@ -94,8 +94,8 @@ options.registry.WebsiteSaleGridLayout = options.Class.extend({
* @override
*/
start: function () {
this.ppg = this.$target.closest('[data-ppg]').data('ppg');
this.ppr = this.$target.closest('[data-ppr]').data('ppr');
this.ppg = parseInt(this.$target.closest('[data-ppg]').data('ppg'));
this.ppr = parseInt(this.$target.closest('[data-ppr]').data('ppr'));
return this._super.apply(this, arguments);
},
/**
@@ -113,29 +113,24 @@ options.registry.WebsiteSaleGridLayout = options.Class.extend({
/**
* @see this.selectClass for params
*/
choosePpg: function (previewMode, value, $opt) {
var self = this;
new Dialog(this, {
title: _t("Choose number of products"),
$content: $(qweb.render('website_sale.dialog.choosePPG', {widget: this})),
buttons: [
{text: _t("Save"), classes: 'btn-primary', click: function () {
var $input = this.$('input');
var def = self._setPPG($input.val());
if (!def) {
$input.addClass('is-invalid');
return;
}
return def.then(this.close.bind(this));
}},
{text: _t("Discard"), close: true},
],
}).open();
setPpg: function (previewMode, value, $opt) {
const ppg = parseInt($opt.find('input').val());
if (!ppg || ppg < 1) {
return false;
}
this.ppg = ppg;
return this._rpc({
route: '/shop/change_ppg',
params: {
'ppg': ppg,
},
}).then(() => reload());
},
/**
* @see this.selectClass for params
*/
setPpr: function (previewMode, value, $opt) {
this.ppr = parseInt(value);
this._rpc({
route: '/shop/change_ppr',
params: {
@@ -152,33 +147,17 @@ options.registry.WebsiteSaleGridLayout = options.Class.extend({
* @override
*/
_setActive: function () {
var self = this;
this._super.apply(this, arguments);
this._super(...arguments);
this.$el.find('[data-set-ppr]')
.addBack('[data-set-ppr]')
.removeClass('active')
.filter(function () {
var nbColumns = $(this).data('setPpr');
return nbColumns === self.ppr;
.filter((i, el) => {
var nbColumns = parseInt($(el).data('setPpr'));
return nbColumns === this.ppr;
})
.addClass('active');
},
/**
* @private
* @param {integer} ppg
* @returns {Promise|false}
*/
_setPPG: function (ppg) {
ppg = parseInt(ppg);
if (!ppg || ppg < 1) {
return false;
}
return this._rpc({
route: '/shop/change_ppg',
params: {
'ppg': ppg,
},
}).then(reload);
this.$el.find('[data-set-ppg] > input').val(this.ppg);
},
});
@@ -1,17 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<div t-name="website_sale.dialog.choosePPG">
<div class="form-group row">
<label for="ppg_input" class="col-lg-auto col-form-label">
Choose the number of products to display
</label>
<div class="col">
<input type="number" id="ppg_input" class="form-control" t-att-value="widget.ppg"/>
</div>
</div>
</div>
<!-- Products Search Bar settings dialog -->
<div t-name="website_sale.dialog.productsSearchBar">
<div class="form-group">
+1 -1
View File
@@ -60,7 +60,7 @@
<div data-js="WebsiteSaleGridLayout"
data-selector="#products_grid .o_wsale_products_grid_table_wrapper > table"
data-no-check="true">
<we-button data-no-preview="true" data-choose-ppg="1">Choose number of products</we-button>
<we-input string="Number of products" data-set-ppg="" data-unit="" data-no-preview="true"/>
<we-select string="Number of Columns" class="o_wsale_ppr_submenu" data-no-preview="true">
<we-button data-set-ppr="2">2</we-button>
<we-button data-set-ppr="3">3</we-button>