diff --git a/addons/mass_mailing/views/snippets_themes.xml b/addons/mass_mailing/views/snippets_themes.xml
index 60cea89838c..65dc66185cb 100644
--- a/addons/mass_mailing/views/snippets_themes.xml
+++ b/addons/mass_mailing/views/snippets_themes.xml
@@ -376,6 +376,7 @@
4
5
6
+ Custom
diff --git a/addons/web_editor/static/src/js/common/column_layout_mixin.js b/addons/web_editor/static/src/js/common/column_layout_mixin.js
new file mode 100644
index 00000000000..ba0a783a324
--- /dev/null
+++ b/addons/web_editor/static/src/js/common/column_layout_mixin.js
@@ -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;
+ },
+};
diff --git a/addons/web_editor/static/src/js/common/grid_layout_utils.js b/addons/web_editor/static/src/js/common/grid_layout_utils.js
index c076156e8ff..b86bb5b4f59 100644
--- a/addons/web_editor/static/src/js/common/grid_layout_utils.js
+++ b/addons/web_editor/static/src/js/common/grid_layout_utils.js
@@ -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);
});
diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js
index b9d5c3357a3..d5d01a7b237 100644
--- a/addons/web_editor/static/src/js/editor/snippets.options.js
+++ b/addons/web_editor/static/src/js/editor/snippets.options.js
@@ -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]);
+ },
});
/**
diff --git a/addons/web_editor/static/src/scss/web_editor.frontend.scss b/addons/web_editor/static/src/scss/web_editor.frontend.scss
index ca9c1626c82..d4f77c208a0 100644
--- a/addons/web_editor/static/src/scss/web_editor.frontend.scss
+++ b/addons/web_editor/static/src/scss/web_editor.frontend.scss
@@ -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;
+ }
}
}
diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js
index b8c9dfedc8e..38dcb362bf8 100644
--- a/addons/website/static/src/js/editor/snippets.options.js
+++ b/addons/website/static/src/js/editor/snippets.options.js
@@ -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,
diff --git a/addons/website/static/src/snippets/s_color_blocks_2/000.scss b/addons/website/static/src/snippets/s_color_blocks_2/000.scss
index 9928677954e..cc6710e8e3e 100644
--- a/addons/website/static/src/snippets/s_color_blocks_2/000.scss
+++ b/addons/website/static/src/snippets/s_color_blocks_2/000.scss
@@ -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%;
diff --git a/addons/website/static/tests/tours/website_update_column_count.js b/addons/website/static/tests/tours/website_update_column_count.js
new file mode 100644
index 00000000000..ea67c5612bf
--- /dev/null
+++ b/addons/website/static/tests/tours/website_update_column_count.js
@@ -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,
+},
+]);
diff --git a/addons/website/tests/test_ui.py b/addons/website/tests/test_ui.py
index cb448f0cdcc..101b3f82ce7 100644
--- a/addons/website/tests/test_ui.py
+++ b/addons/website/tests/test_ui.py
@@ -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")
diff --git a/addons/website/views/snippets/snippets.xml b/addons/website/views/snippets/snippets.xml
index c64df654163..d096f0b625e 100644
--- a/addons/website/views/snippets/snippets.xml
+++ b/addons/website/views/snippets/snippets.xml
@@ -360,6 +360,7 @@
4
5
6
+ Custom