[IMP] web_editor, website, *: allow columns on mobile snippets

*: mass_mailing

This commit introduces options for the flex column layout and the
horizontal resizing to be used on mobile and be independent from
the desktop layout.

This makes it possible to:
- Have different number of columns on mobile and on desktop.
- Use different column widths and offsets for mobile and desktop.
- Reorder columns independently.

The behavior expected from the columns is:
- In the editor panel, Cols indicate the number of columns per row on
both mobile and desktop. The option is conditional of your environment:
you edit the number for the screen size of your current resolution.
- When columns have different sizes (either because it is the default
behavior of the snippet, or because the user resized some), the counter
should read "Custom". Same when the user changed some offsets.
- If manual modifications were made on width and offset, they are reset
on the current display if the number of elements is updated. This was
decided because an old, specific layout with custom offsets / width
doesn't work well with a different number of elements. (This is also the
default behavior before this PR with desktop-only modifications.)

The expected behavior when reordering is:
- On mobile, it should only affect the mobile layout.
- On desktop, it should affect both layouts.

In order to have a coherent feature on both mobile and desktop as well
as correct some non-ideal but non-blocking behaviors, the commit also
changes the following behaviors:
- When setting a columns count lower than the current number of items in
the `.row` container, extra items are now wrapped on the next flex-rows
(still within the container) instead of being deleted.
- When going from e.g. 3 to 5 columns (and as many items), the change
happens all at once instead of each item being visibly added one by one
on the next row, then brought back to the first row.
- The columns count automatically updates when resizing an item, instead
of updating only after you click somewhere else on the snippet.

This commit also adds a test to validate the new behavior.

task-3097045

closes odoo/odoo#117562

Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
This commit is contained in:
Robin Lejeune (role)
2023-10-20 19:12:43 +00:00
parent cd58f04bea
commit 710d000f18
10 changed files with 506 additions and 108 deletions
@@ -376,6 +376,7 @@
<we-button data-select-count="4">4</we-button>
<we-button data-select-count="5">5</we-button>
<we-button data-select-count="6">6</we-button>
<we-button data-select-count="custom" data-name="custom_cols_opt">Custom</we-button>
</we-select>
</div>
@@ -0,0 +1,99 @@
/** @odoo-module **/
export const ColumnLayoutMixin = {
/**
* Calculates the number of columns for the mobile or desktop version.
* If all elements don't have the same size, returns "custom".
*
* @private
* @param {HTMLCollection} columnEls - elements in the .row container
* @param {boolean} isMobile
* @returns {integer|string} number of columns or "custom"
*/
_getNbColumns(columnEls, isMobile) {
if (!columnEls) {
return 0;
}
if (this._areColsCustomized(columnEls, isMobile)) {
return "custom";
}
const resolutionModifier = isMobile ? "" : "lg-";
const colRegex = new RegExp(`(?:^|\\s+)col-${resolutionModifier}(\\d{1,2})(?!\\S)`);
const colSize = parseInt(columnEls[0].className.match(colRegex)?.[1] || 12);
const offsetSize = this._getFirstItem(columnEls, isMobile).classList
.contains(`offset-${resolutionModifier}1`) ? 1 : 0;
return Math.floor((12 - offsetSize) / colSize);
},
/**
* Retrieves the mobile order class as a match array if there is one.
*
* @private
* @param {HTMLElement} el
* @returns {Array|null} class match ["o_we_mobile_order_XX", "XX"]
*/
_getItemMobileOrder(el) {
return el.className.match(/\bo_we_mobile_order_([0-9]+)\b/);
},
/**
* Gets the first item, whether it has a mobile order class or not.
*
* @private
* @param {HTMLCollection} columnEls - elements in the .row container
* @param {boolean} isMobile
* @returns {HTMLElement} first HTMLElement in order
*/
_getFirstItem(columnEls, isMobile) {
return isMobile && [...columnEls].find(el => el.classList.contains("o_we_mobile_order_0"))
|| columnEls[0];
},
/**
* Adds the classes for mobile order.
*
* @private
* @param {HTMLCollection} columnEls - elements in the .row container
*/
_addMobileOrders(columnEls) {
for (let i = 0; i < columnEls.length; i++) {
const mobileOrderClass = `o_we_mobile_order_${i}`;
columnEls[i].classList.add(mobileOrderClass);
}
},
/**
* Checks whether some columns were resized or were added offsets manually.
*
* @private
* @param {HTMLElement} columnEls
* @param {boolean} isMobile
* @returns {boolean}
*/
_areColsCustomized(columnEls, isMobile) {
const resolutionModifier = isMobile ? "" : "lg-";
const colRegex = new RegExp(`(?:^|\\s+)col-${resolutionModifier}(\\d{1,2})(?!\\S)`);
const colSize = parseInt(columnEls[0].className.match(colRegex)?.[1] || 12);
// Cases where we know the columns sizes and/or offsets are NOT custom:
// - if all columns have an equal size AND
// - if there are no offsets OR
// - if, with 5 columns, there is exactly one offset-1 and it's on
// the 1st item
// Any other case is custom.
const allColsSizesEqual = [...columnEls].every((columnEl) =>
parseInt(columnEl.className.match(colRegex)?.[1] || 12) === colSize);
if (!allColsSizesEqual) {
return true;
}
const offsetRegex = new RegExp(`(?:^|\\s+)offset-${resolutionModifier}[1-9][0-1]?(?!\\S)`);
const nbOffsets = [...columnEls]
.filter((columnEl) => columnEl.className.match(offsetRegex)).length;
if (nbOffsets === 0) {
return false;
}
if (nbOffsets === 1 && colSize === 2 && this._getFirstItem(columnEls, isMobile).className
.match(`offset-${resolutionModifier}1`)) {
return false;
}
return true;
},
};
@@ -223,7 +223,7 @@ function _placeColumns(columnEls, rowSize, rowGap, columnSize, columnGap) {
for (const [i, columnEl] of [...columnEls].entries()) {
// Removing padding and offset classes.
const regex = /^(pt|pb|col-|offset-)/;
const regex = /^(((pt|pb)\d{1,3}$)|col-lg-|offset-lg-)/;
const toRemove = [...columnEl.classList].filter(c => {
return regex.test(c);
});
@@ -10,6 +10,7 @@ import Widget from "@web/legacy/js/core/widget";
import { ColorPalette } from "@web_editor/js/wysiwyg/widgets/color_palette";
import weUtils from "@web_editor/js/common/utils";
import * as gridUtils from "@web_editor/js/common/grid_layout_utils";
import {ColumnLayoutMixin} from "@web_editor/js/common/column_layout_mixin";
const {
normalizeColor,
getBgImageURL,
@@ -4443,6 +4444,7 @@ registry.sizing = SnippetOptionWidget.extend({
start: function () {
const self = this;
const def = this._super.apply(this, arguments);
let isMobile = weUtils.isMobileView(this.$target[0]);
this.$handles = this.$overlay.find('.o_handle');
@@ -4450,12 +4452,7 @@ registry.sizing = SnippetOptionWidget.extend({
this.$handles.on('mousedown', function (ev) {
ev.preventDefault();
self.options.wysiwyg.odooEditor.automaticStepUnactive('resizing');
// If the handle has the class 'readonly', don't allow to resize.
// (For the grid handles when we are in mobile view).
if (ev.currentTarget.classList.contains('readonly')) {
return;
}
isMobile = weUtils.isMobileView(self.$target[0]);
// First update size values as some element sizes may not have been
// initialized on option start (hidden slides, etc)
@@ -4513,7 +4510,7 @@ registry.sizing = SnippetOptionWidget.extend({
// front of the other elements.
const rowEl = self.$target[0].parentNode;
let backgroundGridEl;
if (rowEl.classList.contains('o_grid_mode')) {
if (rowEl.classList.contains("o_grid_mode") && !isMobile) {
self.options.wysiwyg.odooEditor.observerUnactive('displayBackgroundGrid');
backgroundGridEl = gridUtils._addBackgroundGrid(rowEl, 0);
self.options.wysiwyg.odooEditor.observerActive('displayBackgroundGrid');
@@ -4606,7 +4603,7 @@ registry.sizing = SnippetOptionWidget.extend({
// If we are in grid mode, removes the background grid.
// Also sync the col-* class with the g-col-* class so the
// toggle to normal mode and the mobile view are well done.
if (rowEl.classList.contains('o_grid_mode')) {
if (rowEl.classList.contains("o_grid_mode") && !isMobile) {
self.options.wysiwyg.odooEditor.observerUnactive('displayBackgroundGrid');
backgroundGridEl.remove();
self.options.wysiwyg.odooEditor.observerActive('displayBackgroundGrid');
@@ -4635,7 +4632,7 @@ registry.sizing = SnippetOptionWidget.extend({
for (const [key, value] of Object.entries(resizeValues)) {
this.$handles.filter('.' + key).toggleClass('readonly', !value);
}
if (this.$target[0].classList.contains('o_grid_item')) {
if (!isMobile && this.$target[0].classList.contains("o_grid_item")) {
this.$handles.filter('.o_grid_handle').toggleClass('readonly', false);
}
@@ -4669,16 +4666,17 @@ registry.sizing = SnippetOptionWidget.extend({
await this._super(...arguments);
const isMobileView = weUtils.isMobileView(this.$target[0]);
const isGrid = this.$target[0].classList.contains('o_grid_item');
const isGridOn = this.$target[0].classList.contains("o_grid_item");
const isGrid = !isMobileView && isGridOn;
if (this.$target[0].parentNode && this.$target[0].parentNode.classList.contains('row')) {
// Hiding/showing the correct resize handles if we are in grid mode
// or not.
for (const handleEl of this.$handles) {
const isGridHandle = handleEl.classList.contains('o_grid_handle');
handleEl.classList.toggle('d-none', isGrid ^ isGridHandle);
// Disabling the resize if we are in mobile view.
const isHorizontalSizing = handleEl.matches('.e, .w');
handleEl.classList.toggle('readonly', isMobileView && (isHorizontalSizing || isGridHandle));
// Disabling the vertical resize if we are in mobile view.
const isVerticalSizing = handleEl.matches('.n, .s');
handleEl.classList.toggle("readonly", isMobileView && isVerticalSizing && isGridOn);
}
// Hiding the move handle in mobile view so we can't drag the
@@ -4688,7 +4686,7 @@ registry.sizing = SnippetOptionWidget.extend({
// Show/hide the buttons to send back/front a grid item.
const bringFrontBackEls = this.$overlay[0].querySelectorAll('.o_front_back');
bringFrontBackEls.forEach(button => button.classList.toggle('d-none', !isGrid || isMobileView));
bringFrontBackEls.forEach(button => button.classList.toggle("d-none", !isGrid));
}
},
@@ -4812,8 +4810,10 @@ registry['sizing_x'] = registry.sizing.extend({
// Below condition is added to remove offset of target element only
// and not its children to avoid design alteration of a container/block.
if (options.isCurrent) {
var _class = this.$target.attr('class').replace(/\s*(offset-xl-|offset-lg-)([0-9-]+)/g, '');
this.$target.attr('class', _class);
const targetClassList = this.$target[0].classList;
const offsetClasses = [...targetClassList]
.filter(cls => cls.match(/^offset-(lg-)?([0-9]{1,2})$/));
targetClassList.remove(...offsetClasses);
}
},
@@ -4825,12 +4825,22 @@ registry['sizing_x'] = registry.sizing.extend({
* @override
*/
_getSize: function () {
const isMobileView = weUtils.isMobileView(this.$target[0]);
const resolutionModifier = isMobileView ? "" : "lg-";
var width = this.$target.closest('.row').width();
var gridE = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
var gridW = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
this.grid = {
e: [gridE.map(v => ('col-lg-' + v)), gridE.map(v => width / 12 * v), 'width'],
w: [gridW.map(v => ('offset-lg-' + v)), gridW.map(v => width / 12 * v), 'margin-left'],
e: [
gridE.map(v => (`col-${resolutionModifier}${v}`)),
gridE.map(v => width / 12 * v),
"width",
],
w: [
gridW.map(v => (`offset-${resolutionModifier}${v}`)),
gridW.map(v => width / 12 * v),
"margin-left",
],
};
return this.grid;
},
@@ -4838,34 +4848,51 @@ registry['sizing_x'] = registry.sizing.extend({
* @override
*/
_onResize: function (compass, beginClass, current) {
const targetEl = this.$target[0];
const isMobileView = weUtils.isMobileView(targetEl);
const resolutionModifier = isMobileView ? "" : "lg-";
if (compass === 'w' || compass === 'e') {
const beginOffset = Number(beginClass.match(/offset-lg-([0-9-]+)|$/)[1] || beginClass.match(/offset-xl-([0-9-]+)|$/)[1] || 0);
// (?!\S): following char cannot be a non-space character
const offsetRegex = new RegExp(`(?:^|\\s+)offset-${resolutionModifier}(\\d{1,2})(?!\\S)`);
const colRegex = new RegExp(`(?:^|\\s+)col-${resolutionModifier}(\\d{1,2})(?!\\S)`);
const beginOffset = Number(beginClass.match(offsetRegex)?.[1] || 0);
if (compass === 'w') {
// don't change the right border position when we change the offset (replace col size)
var beginCol = Number(beginClass.match(/col-lg-([0-9]+)|$/)[1] || 0);
var offset = Number(this.grid.w[0][current].match(/offset-lg-([0-9-]+)|$/)[1] || 0);
const beginCol = Number(beginClass.match(colRegex)?.[1] || 12);
let offset = Number(this.grid.w[0][current].match(offsetRegex)?.[1] || 0);
if (offset < 0) {
offset = 0;
}
var colSize = beginCol - (offset - beginOffset);
let colSize = beginCol - (offset - beginOffset);
if (colSize <= 0) {
colSize = 1;
offset = beginOffset + beginCol - 1;
}
this.$target.attr('class', this.$target.attr('class').replace(/\s*(offset-xl-|offset-lg-|col-lg-)([0-9-]+)/g, ''));
const offsetColRegex = new RegExp(`${offsetRegex.source}|${colRegex.source}`, "g");
targetEl.className = targetEl.className.replace(offsetColRegex, "");
targetEl.classList.add(`col-${resolutionModifier}${colSize > 12 ? 12 : colSize}`);
this.$target.addClass('col-lg-' + (colSize > 12 ? 12 : colSize));
if (offset > 0) {
this.$target.addClass('offset-lg-' + offset);
targetEl.classList.add(`offset-${resolutionModifier}${offset}`);
}
if (isMobileView && offset === 0) {
targetEl.classList.remove("offset-lg-0");
} else if ((isMobileView && offset > 0 &&
!targetEl.className.match(/(^|\s+)offset-lg-\d{1,2}(?!\S)/)) ||
(!isMobileView && offset === 0 &&
targetEl.className.match(/(^|\s+)offset-\d{1,2}(?!\S)/))) {
targetEl.classList.add("offset-lg-0");
}
} else if (beginOffset > 0) {
const endCol = Number(this.grid.e[0][current].match(/col-lg-([0-9]+)|$/)[1] || 0);
const endCol = Number(this.grid.e[0][current].match(colRegex)?.[1] || 0);
// Avoids overflowing the grid to the right if the
// column size + the offset exceeds 12.
if ((endCol + beginOffset) > 12) {
this.$target[0].className = this.$target[0].className.replace(/\s*(col-lg-)([0-9-]+)/g, '');
this.$target[0].classList.add('col-lg-' + (12 - beginOffset));
targetEl.className = targetEl.className.replace(colRegex, "");
targetEl.classList.add(`col-${resolutionModifier}${12 - beginOffset}`);
}
}
}
@@ -4875,6 +4902,10 @@ registry['sizing_x'] = registry.sizing.extend({
* @override
*/
async _notifyResizeChange() {
this.trigger_up("option_update", {
optionName: "layout_column",
name: "change_column_size",
});
this.trigger_up('option_update', {
optionName: 'StepsConnector',
name: 'change_column_size',
@@ -5094,13 +5125,22 @@ registry.Box = SnippetOptionWidget.extend({
registry.layout_column = SnippetOptionWidget.extend({
registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, {
/**
* @override
*/
cleanUI() {
this._removeGridPreview();
},
/**
* @override
*/
notify(name) {
if (name === "change_column_size") {
this.updateUI();
}
this._super(...arguments);
},
//--------------------------------------------------------------------------
// Options
@@ -5112,6 +5152,10 @@ registry.layout_column = SnippetOptionWidget.extend({
* @see this.selectClass for parameters
*/
selectCount: async function (previewMode, widgetValue, params) {
// Make sure the "Custom" option is read-only.
if (widgetValue === "custom") {
return;
}
const previousNbColumns = this.$('> .row').children().length;
let $row = this.$('> .row');
if (!$row.length) {
@@ -5122,7 +5166,7 @@ registry.layout_column = SnippetOptionWidget.extend({
}
const nbColumns = parseInt(widgetValue);
await this._updateColumnCount($row, (nbColumns || 1) - $row.children().length);
await this._updateColumnCount($row[0], (nbColumns || 1));
// Yield UI thread to wait for event to bubble before activate_snippet is called.
// In this case this lets the select handle the click event before we switch snippet.
// TODO: make this more generic in activate_snippet event handler.
@@ -5280,7 +5324,9 @@ registry.layout_column = SnippetOptionWidget.extend({
*/
_computeWidgetState: function (methodName, params) {
if (methodName === 'selectCount') {
return this.$('> .row').children().length;
const isMobile = this._isMobile();
const columnEls = this.$target[0].querySelector(":scope > .row")?.children;
return this._getNbColumns(columnEls, isMobile);
} else if (methodName === 'selectLayout') {
const rowEl = this.$target[0].querySelector('.row');
if (rowEl && rowEl.classList.contains('o_grid_mode')) {
@@ -5306,59 +5352,79 @@ registry.layout_column = SnippetOptionWidget.extend({
// Hide the selectCount widget if the `s_nb_column_fixed` class is
// on the row.
return !this.$target[0].querySelector(":scope > .row.s_nb_column_fixed");
} else if (widgetName === "custom_cols_opt") {
// Show "Custom" if the user altered the columns in some way (i.e.
// by adding offsets or resizing a column). This is only shown as
// an indication, but shouldn't be selectable.
const isMobile = this._isMobile();
return this.$target[0].querySelector(":scope > .row") &&
this._areColsCustomized(this.$target[0].querySelector(":scope > .row").children,
isMobile);
}
return this._super(...arguments);
},
/**
* Adds new columns which are clones of the last column or removes the
* last x columns.
* If the number of columns requested is greater than the number of items,
* adds new columns which are clones of the last one. If there are less
* columns than the number of items, reorganizes the elements on the right
* amount of rows.
*
* @private
* @param {jQuery} $row - the row in which to update the columns
* @param {integer} count - positif to add, negative to remove
* @param {HTMLElement} rowEl - the row in which to update the columns
* @param {integer} nbColumns - the number of columns requested
*/
_updateColumnCount: async function ($row, count) {
if (!count) {
async _updateColumnCount(rowEl, nbColumns) {
const isMobile = this._isMobile();
// The number of elements per row before the update.
const prevNbColumns = this._getNbColumns(rowEl.children, isMobile);
if (nbColumns === prevNbColumns) {
return;
}
this._resizeColumns(rowEl.children, nbColumns);
if (count > 0) {
var $lastColumn = $row.children().last();
for (var i = 0; i < count; i++) {
await new Promise(resolve => {
this.trigger_up('clone_snippet', {$snippet: $lastColumn, onSuccess: resolve});
});
}
} else {
var self = this;
for (const el of $row.children().slice(count)) {
await new Promise(resolve => {
self.trigger_up('remove_snippet', {$snippet: $(el), onSuccess: resolve, shouldRecordUndo: false});
});
const itemsDelta = nbColumns - rowEl.children.length;
if (itemsDelta > 0) {
const newItems = [];
for (let i = 0; i < itemsDelta; i++) {
const lastEl = rowEl.lastElementChild;
newItems.push(new Promise(resolve => {
this.trigger_up("clone_snippet", {$snippet: $(lastEl), onSuccess: resolve});
}));
}
await Promise.all(newItems);
}
this._resizeColumns($row.children());
this.trigger_up('cover_update');
},
/**
* Resizes the columns so that they are kept on one row.
* Resizes the columns for the mobile or desktop view.
*
* @private
* @param {jQuery} $columns - the columns to resize
* @param {HTMLCollection} columnEls - the elements to resize
* @param {integer} nbColumns - the number of wanted columns
*/
_resizeColumns: function ($columns) {
const colsLength = $columns.length;
var colSize = Math.floor(12 / colsLength) || 1;
var colOffset = Math.floor((12 - colSize * colsLength) / 2);
var colClass = 'col-lg-' + colSize;
$columns.toArray().forEach((column) => {
var $column = $(column);
$column.attr('class', $column.attr('class').replace(/\b(col|offset)-lg(-\d+)?\b/g, ''));
$column.addClass(colClass);
});
if (colOffset) {
$columns.first().addClass('offset-lg-' + colOffset);
_resizeColumns(columnEls, nbColumns) {
const isMobile = this._isMobile();
const itemSize = Math.floor(12 / nbColumns) || 1;
const firstItem = this._getFirstItem(columnEls, isMobile);
const firstItemOffset = Math.floor((12 - itemSize * nbColumns) / 2);
const resolutionModifier = isMobile ? "" : "-lg";
const replacingRegex =
// (?!\S): following char cannot be a non-space character
new RegExp(`(?:^|\\s+)(col|offset)${resolutionModifier}(-\\d{1,2})?(?!\\S)`, "g");
for (const columnEl of columnEls) {
columnEl.className = columnEl.className.replace(replacingRegex, "");
columnEl.classList.add(`col${resolutionModifier}-${itemSize}`);
if (firstItemOffset && columnEl === firstItem) {
columnEl.classList.add(`offset${resolutionModifier}-${firstItemOffset}`);
}
const hasMobileOffset = columnEl.className.match(/(^|\s+)offset-\d{1,2}(?!\S)/);
const hasDesktopOffset = columnEl.className.match(/(^|\s+)offset-lg-[1-9][0-1]?(?!\S)/);
columnEl.classList.toggle("offset-lg-0", hasMobileOffset && !hasDesktopOffset);
}
},
/**
@@ -5407,6 +5473,12 @@ registry.layout_column = SnippetOptionWidget.extend({
delete this.removeGridPreview;
this.options.wysiwyg.odooEditor.observerActive("removeGridPreview");
},
/**
* @returns {boolean}
*/
_isMobile() {
return weUtils.isMobileView(this.$target[0]);
},
});
registry.GridColumns = SnippetOptionWidget.extend({
@@ -5489,7 +5561,7 @@ registry.vAlignment = SnippetOptionWidget.extend({
/**
* Allows snippets to be moved before the preceding element or after the following.
*/
registry.SnippetMove = SnippetOptionWidget.extend({
registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, {
displayOverlayOptions: true,
/**
@@ -5504,6 +5576,49 @@ registry.SnippetMove = SnippetOptionWidget.extend({
return this._super(...arguments);
},
/**
* @override
*/
onClone(options) {
this._super.apply(this, arguments);
const mobileOrder = this._getItemMobileOrder(this.$target[0]);
// If the order has been adapted on mobile, it must be different
// for each clone.
if (options.isCurrent && mobileOrder) {
const siblingEls = this.$target[0].parentElement.children;
const cloneEls = [...siblingEls].filter(el => el.classList.contains(mobileOrder[0]));
// For cases in which multiple clones are made at the same time, we
// change the order for all clones at once. (e.g.: it happens when
// increasing the columns count.) This makes sure the clones get a
// mobile order in line with their DOM order.
cloneEls.forEach((el, i) => {
if (i > 0) {
const newMobileOrder = siblingEls.length - cloneEls.length + i;
el.classList.replace(mobileOrder[0], `o_we_mobile_order_${newMobileOrder}`);
}
});
}
},
/**
* @override
*/
onRemove() {
this._super.apply(this, arguments);
const targetMobileOrder = this._getItemMobileOrder(this.$target[0]);
// If the order has been adapted on mobile, the gap created by the
// removed snippet must be filled in.
if (targetMobileOrder) {
const targetOrder = parseInt(targetMobileOrder[1]);
[...this.$target[0].parentElement.children].forEach(el => {
const elOrder = parseInt(this._getItemMobileOrder(el)[1]);
if (elOrder > targetOrder) {
el.classList.replace(`o_we_mobile_order_${elOrder}`,
`o_we_mobile_order_${elOrder - 1}`);
}
});
}
},
//--------------------------------------------------------------------------
// Options
@@ -5515,21 +5630,41 @@ registry.SnippetMove = SnippetOptionWidget.extend({
* @see this.selectClass for parameters
*/
moveSnippet: function (previewMode, widgetValue, params) {
const isMobile = weUtils.isMobileView(this.$target[0]);
const isNavItem = this.$target[0].classList.contains('nav-item');
const $tabPane = isNavItem ? $(this.$target.find('.nav-link')[0].hash) : null;
switch (widgetValue) {
case 'prev':
this.$target.prev().before(this.$target);
if (isNavItem) {
$tabPane.prev().before($tabPane);
const moveLeftOrRight = ["move_left_opt", "move_right_opt"].includes(params.name);
let siblingEls, mobileOrder;
if (moveLeftOrRight) {
siblingEls = this.$target[0].parentElement.children;
mobileOrder = !!this._getItemMobileOrder(this.$target[0]);
}
if (moveLeftOrRight && isMobile && !isNavItem) {
if (!mobileOrder) {
this._addMobileOrders(siblingEls);
}
this._swapMobileOrders(widgetValue, siblingEls);
} else {
switch (widgetValue) {
case "prev":
this.$target[0].previousElementSibling.before(this.$target[0]);
if (isNavItem) {
$tabPane.prev().before($tabPane);
}
break;
case "next":
this.$target[0].nextElementSibling.after(this.$target[0]);
if (isNavItem) {
$tabPane.next().after($tabPane);
}
break;
}
if (mobileOrder) {
for (const el of siblingEls) {
el.className = el.className.replace(/\bo_we_mobile_order_[0-9]+\b/, "");
}
break;
case 'next':
this.$target.next().after(this.$target);
if (isNavItem) {
$tabPane.next().after($tabPane);
}
break;
}
}
if (!this.$target.is(this.data.noScroll)
&& (params.name === 'move_up_opt' || params.name === 'move_down_opt')) {
@@ -5566,6 +5701,7 @@ registry.SnippetMove = SnippetOptionWidget.extend({
async _computeWidgetVisibility(widgetName, params) {
const moveUpOrLeft = widgetName === "move_up_opt" || widgetName === "move_left_opt";
const moveDownOrRight = widgetName === "move_down_opt" || widgetName === "move_right_opt";
const moveLeftOrRight = widgetName === "move_left_opt" || widgetName === "move_right_opt";
if (moveUpOrLeft || moveDownOrRight) {
// The arrows are not displayed if the target is in a grid and if
@@ -5574,11 +5710,32 @@ registry.SnippetMove = SnippetOptionWidget.extend({
if (!isMobileView && this.$target[0].classList.contains("o_grid_item")) {
return false;
}
// On mobile, items' reordering is independent from desktop inside
// a snippet (left or right), not at a higher level (up or down).
if (moveLeftOrRight && isMobileView && this._getItemMobileOrder(this.$target[0])) {
const firstOrLast = widgetName === "move_left_opt" ? "0" :
this.$target[0].parentElement.children.length - 1;
return !this.$target[0].classList.contains(`o_we_mobile_order_${firstOrLast}`);
}
const firstOrLastChild = moveUpOrLeft ? ":first-child" : ":last-child";
return !this.$target.is(firstOrLastChild);
}
return this._super(...arguments);
},
/**
* Swaps the mobile orders.
*
* @param {string} widgetValue
* @param {HTMLCollection} siblingEls
*/
_swapMobileOrders(widgetValue, siblingEls) {
const targetMobileOrder = this._getItemMobileOrder(this.$target[0]);
const orderModifier = widgetValue === "prev" ? -1 : 1;
const newOrderClass = `o_we_mobile_order_${parseInt(targetMobileOrder[1]) + orderModifier}`;
const comparedEl = [...siblingEls].find(el => el.classList.contains(newOrderClass));
this.$target[0].classList.replace(targetMobileOrder[0], newOrderClass);
comparedEl.classList.replace(newOrderClass, targetMobileOrder[0]);
},
});
/**
@@ -32,6 +32,14 @@
transform: scale(-1);
}
@include media-breakpoint-down(lg) {
@for $i from 0 through 12 {
.o_we_mobile_order_#{$i} {
order: $i;
}
}
}
// GRID LAYOUT
.o_grid_mode {
@include media-breakpoint-down(lg) {
@@ -39,39 +47,46 @@
row-gap: 0px !important;
column-gap: 0px !important;
}
@include media-breakpoint-up(lg) {
--grid-item-padding-y: 10px;
--grid-item-padding-x: 10px;
> * {
padding: var(--grid-item-padding-y) var(--grid-item-padding-x) !important;
}
}
@include media-breakpoint-up(lg) {
.o_grid_mode {
display: grid !important;
grid-auto-rows: 50px;
grid-template-columns: repeat(12, 1fr);
row-gap: 0px;
column-gap: 0px;
}
--gutter-x: 0px;
--grid-item-padding-y: 10px;
--grid-item-padding-x: 10px;
> * {
width: 100%;
min-width: 0;
margin: 0 !important;
padding: var(--grid-item-padding-y) var(--grid-item-padding-x) !important;
}
}
--gutter-x: 0px;
.container-fluid > .o_grid_mode {
--gutter-x: 30px;
}
.o_grid_item_image {
> img, > .media_iframe_video {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
> * {
margin: 0 !important;
width: 100%;
min-width: 0;
}
}
&.o_grid_item_image_contain > img,
> img[data-shape] {
object-fit: contain !important;
.container-fluid > .o_grid_mode {
--gutter-x: 30px;
}
.o_grid_item_image {
> img, > .media_iframe_video {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
}
&.o_grid_item_image_contain > img,
> img[data-shape] {
object-fit: contain !important;
}
}
}
@@ -4015,6 +4015,15 @@ options.registry.Button = options.Class.extend({
},
});
options.registry.layout_column.include({
/**
* @override
*/
_isMobile() {
return wUtils.isMobile(this);
},
});
export default {
UrlPickerUserValueWidget: UrlPickerUserValueWidget,
FontFamilyPickerUserValueWidget: FontFamilyPickerUserValueWidget,
@@ -23,11 +23,6 @@
padding-top: 8vw; // A flex item cannot have % padding top and bottom (even if it works on chrome)
padding-bottom: 8vw; // Solution is vw units but we keep 8% as a fallback
}
@include media-breakpoint-down(lg) {
[class*="col-lg-"] {
flex: 1 1 100%;
}
}
img {
max-width: 100%;
@@ -0,0 +1,118 @@
/** @odoo-module **/
import wTourUtils from "@website/js/tours/tour_utils";
const columnCountOptSelector = ".snippet-option-layout_column we-select[data-name='column_count_opt']";
const columnsSnippetRow = "iframe .s_three_columns .row";
wTourUtils.registerWebsitePreviewTour("website_update_column_count", {
test: true,
url: "/",
edition: true,
}, () => [
wTourUtils.dragNDrop({
id: "s_three_columns",
name: "Columns",
}),
wTourUtils.clickOnSnippet({
id: "s_three_columns",
name: "Columns",
}), {
content: "Open the columns count select",
trigger: columnCountOptSelector,
}, {
content: "Set 5 columns on desktop",
trigger: `${columnCountOptSelector} we-button[data-select-count='5']`,
}, {
content: "Check that there are now 5 items on 5 columns, and that it didn't change the mobile layout",
trigger: `${columnsSnippetRow}:has(.col-lg-2:nth-child(5):not(.col-2)):not(:has(:nth-child(6)))`,
isCheck: true,
}, {
content: "Check that there is an offset on the 1st item to center the row on desktop, but not on mobile",
trigger: `${columnsSnippetRow} > .offset-lg-1:not(.offset-1):first-child`,
isCheck: true,
}, {
content: "Open the columns count select",
trigger: columnCountOptSelector,
}, {
content: "Set 2 columns on desktop",
trigger: `${columnCountOptSelector} we-button[data-select-count='2']`,
}, {
content: "Check that there are still 5 items in the row and click on the last one",
trigger: `${columnsSnippetRow} > :nth-child(5)`,
}, {
content: "Delete the item",
trigger: "we-title:contains('Column'):not(:contains('Columns')) .oe_snippet_remove",
}, {
content: "Toggle mobile view",
trigger: ".o_we_website_top_actions [data-action='mobile']",
}, {
content: "Check that there is 1 column on mobile and click on the selector",
trigger: `${columnCountOptSelector} we-toggler:contains('1')`,
}, {
content: "Set 3 columns on mobile",
trigger: `${columnCountOptSelector} we-button[data-select-count='3']`,
}, {
content: "Check that there are still 4 items but on rows of 3 columns",
trigger: `${columnsSnippetRow}:has(.col-lg-6.col-4:nth-child(4))`,
isCheck: true,
},
// As there is no practical way to resize the items through the handles, the
// next step approximates part of what could be reached.
{
content: "Add a fake resized class on mobile to the 2nd item",
trigger: `${columnsSnippetRow} > :nth-child(2)`,
run: ({ tip_widget }) => {
const secondItemEl = tip_widget.$anchor[0];
secondItemEl.classList.replace("col-4", "col-6");
// As this is a hardcoded class replacement, a click is needed to
// update the column count.
secondItemEl.previousElementSibling.click();
},
}, {
content: "Check that the counter shows 'Custom'",
trigger: `${columnCountOptSelector} we-toggler:contains('Custom')`,
isCheck: true,
}, {
content: "Click on the 2nd item",
trigger: `${columnsSnippetRow} > :nth-child(2)`,
}, {
content: "Change the orders of the 2nd and 3rd items",
trigger: "iframe .o_overlay_move_options [data-name='move_right_opt']",
}, {
content: "Check that the 1st item now has a class .o_we_mobile_order_0" +
"and that .o_we_mobile_order_1 is set on the 3rd item, and .o_we_mobile_order_2 on the 2nd",
trigger: `${columnsSnippetRow}:has(.o_we_mobile_order_0:first-child)`,
extra_trigger: `${columnsSnippetRow}:has(.o_we_mobile_order_2:nth-child(2) + .o_we_mobile_order_1:nth-child(3))`,
isCheck: true,
}, {
content: "Toggle desktop view",
trigger: ".o_we_website_top_actions [data-action='mobile']",
}, {
content: "Open the columns count select",
trigger: columnCountOptSelector,
}, {
content: "Add 2 more items through the columns counter",
trigger: `${columnCountOptSelector} we-button[data-select-count='6']`,
}, {
content: "Check that each item has a different mobile order from 0 to 5",
trigger: `${columnsSnippetRow}${[0, 1, 2, 3, 4, 5].map(n => `:has(.o_we_mobile_order_${n})`).join("")}`,
isCheck: true,
}, {
content: "Click on the 6th item",
trigger: `${columnsSnippetRow} > :nth-child(6)`,
}, {
// TODO: remove this step. It should not be needed, but the build fails
// without it.
content: "Wait for move arrows to appear",
trigger: "iframe .o_overlay_move_options [data-name='move_left_opt']:has(+ .d-none[data-name='move_right_opt'])",
isCheck: true,
}, {
content: "Change the orders of the 5th and 6th items to override the mobile orders",
trigger: "iframe .o_overlay_move_options [data-name='move_left_opt']",
}, {
content: "Check that there are no .o_we_mobile_order_X classes anymore",
trigger: `${columnsSnippetRow}:not(:has(.o_we_mobile_order_0))`,
isCheck: true,
},
]);
+3
View File
@@ -446,3 +446,6 @@ class TestUi(odoo.tests.HttpCase):
def test_website_text_font_size(self):
self.start_tour('/@/', 'website_text_font_size', login='admin', timeout=300)
def test_update_column_count(self):
self.start_tour(self.env['website'].get_client_action_url('/'), 'website_update_column_count', login="admin")
@@ -360,6 +360,7 @@
<we-button data-select-count="4">4</we-button>
<we-button data-select-count="5">5</we-button>
<we-button data-select-count="6">6</we-button>
<we-button data-select-count="custom" data-name="custom_cols_opt">Custom</we-button>
</we-select>
</template>