diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
index a11b326991b..987b7d84b6d 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -99,6 +99,9 @@ ListRenderer.include({
this.currentRow = null;
this.currentFieldIndex = null;
this.allRecordsIds = null; // flat array of records ids used by navigation
+ this.isResizing = false;
+ this.hasBeenResized = false;
+ this.eventListeners = [];
},
/**
* @override
@@ -110,6 +113,18 @@ ListRenderer.include({
}
return this._super();
},
+ /**
+ * Overriden to unbind all attached listeners
+ *
+ * @override
+ */
+ destroy: function () {
+ this.eventListeners.forEach(listener => {
+ const { type, el, callback, options } = listener;
+ el.removeEventListener(type, callback, options);
+ });
+ return this._super.apply(this, arguments);
+ },
/**
* The list renderer needs to know if it is in the DOM, and to be notified
* when it is attached to the DOM to properly compute column widths.
@@ -511,6 +526,23 @@ ListRenderer.include({
// Private
//--------------------------------------------------------------------------
+ /**
+ * Used to bind event listeners so that they can be unbound when the list
+ * is destroyed.
+ * There is no reverse method (list._removeEventListener) because there is
+ * no issue with removing an non-existing listener.
+ *
+ * @private
+ * @param {string} type event name
+ * @param {EventTarget} el event target
+ * @param {Function} callback callback function to attach
+ * @param {Object} options event listener options
+ */
+ _addEventListener: function (type, el, callback, options) {
+ el.addEventListener(type, callback, options);
+ this.eventListeners.push({ type, el, callback, options });
+ },
+
/**
* Destroy all field widgets corresponding to a record. Useful when we are
* removing a useless row.
@@ -545,12 +577,20 @@ ListRenderer.include({
// widths w.r.t. their label
return;
}
- var $thead = this.$('thead');
- $thead.find('th').each((index, th) => {
- var $th = $(th);
- $th.css('width', this.columnWidths ? this.columnWidths[index] : $th.outerWidth() + 'px');
+ // Freeze table width according to its size in fixed layout
+ const table = this.el.getElementsByTagName('table')[0];
+ const thead = table.getElementsByTagName('thead')[0];
+ table.style.tableLayout = 'fixed';
+ table.style.width = `${table.offsetWidth}px`;
+
+ // Freeze each th width according to their size in auto layout
+ table.style.tableLayout = 'auto';
+ [...thead.getElementsByTagName('th')].forEach((th, index) => {
+ th.style.width = `${this.columnWidths ? this.columnWidths[index] : th.offsetWidth}px`;
});
- this.$('table').css('table-layout', 'fixed');
+
+ // Finally set the table layout to fixed
+ table.style.tableLayout = 'fixed';
},
/**
* Returns the width of a column according the 'width' attribute set in the
@@ -970,8 +1010,27 @@ ListRenderer.include({
if (this.addTrashIcon) {
$thead.find('tr').append($('
', {class: 'o_list_record_remove_header'}));
}
+ this.hasBeenResized = false;
return $thead;
},
+ /**
+ * Overriden to add a resize handle in editable list column headers.
+ * Only applies to data column headers
+ *
+ * @override
+ * @private
+ */
+ _renderHeaderCell: function () {
+ const $th = this._super.apply(this, arguments);
+ if (this.editable && this._hasVisibleRecords(this.state)) {
+ const resizeHandle = document.createElement('span');
+ resizeHandle.classList = 'o_resize';
+ resizeHandle.onclick = this._onClickResize.bind(this);
+ resizeHandle.onmousedown = this._onStartResize.bind(this);
+ $th.append(resizeHandle);
+ }
+ return $th;
+ },
/**
* Editable rows are possibly extended with a trash icon on their right, to
* allow deleting the corresponding record.
@@ -1212,6 +1271,16 @@ ListRenderer.include({
var fieldIndex = Math.max($tr.find('.o_data_cell').not('.o_list_button').index($td), 0);
this._selectCell(rowIndex, fieldIndex, {event: event});
},
+ /**
+ * We want to override any default mouse behaviour when clicking on the resize handles
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickResize: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ },
/**
* We need to manually unselect row, because no one else would do it
*/
@@ -1372,10 +1441,100 @@ ListRenderer.include({
* @private
*/
_onSortColumn: function () {
- if (this.currentRow === null) {
+ if (this.currentRow === null && !this.isResizing) {
this._super.apply(this, arguments);
}
},
+ /**
+ * Handles the resize feature on the column headers
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onStartResize: function (ev) {
+ // Only triggered by left mouse button
+ if (ev.which !== 1) {
+ return;
+ }
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ if (!this.hasBeenResized) {
+ this._freezeColumnWidths();
+ }
+ this.isResizing = true;
+ this.hasBeenResized = true;
+
+ const table = this.el.getElementsByTagName('table')[0];
+ const th = ev.target.closest('th');
+ const thPosition = [...th.parentNode.children].indexOf(th);
+ const resizingColumnElements = [...table.getElementsByTagName('tr')]
+ .filter(tr => tr.children.length === th.parentNode.children.length)
+ .map(tr => tr.children[thPosition]);
+ const optionalDropdown = this.el.getElementsByClassName('o_optional_columns')[0];
+ const initialX = ev.pageX;
+ const initialWidth = th.offsetWidth;
+ const initialTableWidth = table.offsetWidth;
+ const initialDropdownX = optionalDropdown ? optionalDropdown.offsetLeft : null;
+ const resizeStoppingEvents = [
+ 'keydown',
+ 'mousedown',
+ 'mouseup',
+ ];
+
+ // Apply classes to table and selected column
+ table.classList.add('o_resizing');
+ resizingColumnElements.forEach(el => el.classList.add('o_column_resizing'));
+
+ // Mousemove event : resize header
+ const resizeHeader = ev => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const delta = ev.pageX - initialX;
+ const newWidth = Math.max(10, initialWidth + delta);
+ const tableDelta = newWidth - initialWidth;
+ th.style.width = `${newWidth}px`;
+ table.style.width = `${initialTableWidth + tableDelta}px`;
+ if (optionalDropdown) {
+ optionalDropdown.style.left = `${initialDropdownX + tableDelta}px`;
+ }
+ };
+ this._addEventListener('mousemove', window, resizeHeader);
+
+ // Mouse or keyboard events : stop resize
+ const stopResize = ev => {
+ // Ignores the initial 'left mouse button down' event in order
+ // to not instantly remove the listener
+ if (ev.type === 'mousedown' && ev.which === 1) {
+ return;
+ }
+ ev.preventDefault();
+ ev.stopPropagation();
+ // We need a small timeout to not trigger a click on column header
+ clearTimeout(this.resizeTimeout);
+ this.resizeTimeout = setTimeout(() => {
+ this.isResizing = false;
+ }, 100);
+ window.removeEventListener('mousemove', resizeHeader);
+ table.classList.remove('o_resizing');
+ resizingColumnElements.forEach(el => el.classList.remove('o_column_resizing'));
+ resizeStoppingEvents.forEach(stoppingEvent => {
+ window.removeEventListener(stoppingEvent, stopResize);
+ });
+
+ // we remove the focus to make sure that the there is no focus inside
+ // the tr. If that is the case, there is some css to darken the whole
+ // thead, and it looks quite weird with the small css hover effect.
+ document.activeElement.blur();
+ };
+ // We have to listen to several events to properly stop the resizing function. Those are:
+ // - mousedown (e.g. pressing right click)
+ // - mouseup : logical flow of the resizing feature (drag & drop)
+ // - keydown : (e.g. pressing 'Alt' + 'Tab' or 'Windows' key)
+ resizeStoppingEvents.forEach(stoppingEvent => {
+ this._addEventListener(stoppingEvent, window, stopResize);
+ });
+ },
/**
* Unselect the row before adding the optional column to the listview
*
diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss
index d3c2135ee85..205dbe919ae 100644
--- a/addons/web/static/src/scss/list_view.scss
+++ b/addons/web/static/src/scss/list_view.scss
@@ -180,10 +180,23 @@
//--------------------------------------------------------------------------
&.o_editable_list {
+ &.o_resizing tr > :not(.o_column_resizing) {
+ opacity: 0.5;
+ }
+
&.o_empty_list {
table-layout: fixed;
}
+ span.o_resize {
+ bottom: 0;
+ cursor: col-resize;
+ position: absolute;
+ right: 0;
+ top: 0;
+ width: 10px;
+ }
+
.o_list_record_remove_header {
width: 32px;
}
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index 86fe8bc65c6..838a626fb53 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -1472,7 +1472,7 @@ QUnit.module('Views', {
{ field: 'datetime', expected: 146, type: 'Datetime' },
{ field: 'amount', expected: 104, type: 'Monetary' },
];
- assert.expect(assertions.length + 1);
+ assert.expect(8);
this.data.foo.records = [];
var list = await createView({
@@ -1490,6 +1490,7 @@ QUnit.module('Views', {
'',
});
+ assert.containsNone(list, 'o_resize', "There shouldn't be any resize handle if no data");
assertions.forEach(a => {
assert.strictEqual(list.$(`th[data-name="${a.field}"]`)[0].offsetWidth, a.expected,
`Field ${a.type} should have a fixed width of ${a.expected} pixels`);
@@ -1509,7 +1510,7 @@ QUnit.module('Views', {
{ field: 'datetime', expected: 146, type: 'Datetime' },
{ field: 'amount', expected: 104, type: 'Monetary' },
];
- assert.expect(assertions.length + 1);
+ assert.expect(8);
var list = await createView({
View: ListView,
@@ -1527,6 +1528,7 @@ QUnit.module('Views', {
groupBy: ['int_field'],
});
+ assert.containsNone(list, 'o_resize', "There shouldn't be any resize handle if no data");
assertions.forEach(a => {
assert.strictEqual(list.$(`th[data-name="${a.field}"]`)[0].offsetWidth, a.expected,
`Field ${a.type} should have a fixed width of ${a.expected} pixels`);
@@ -6992,6 +6994,40 @@ QUnit.module('Views', {
});
// TODO: write test on:
// - default_get with a field not in view
+
+ QUnit.test('editable list: resize column headers', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '' +
+ '' +
+ '' +
+ '',
+ });
+
+ // Target handle
+ const th = list.el.getElementsByTagName('th')[1];
+ const optionalDropdown = list.el.getElementsByClassName('o_optional_columns')[0];
+ const optionalInitialX = optionalDropdown.getBoundingClientRect().x;
+ const resizeHandle = th.getElementsByClassName('o_resize')[0];
+ const originalWidth = th.offsetWidth;
+ const expectedWidth = Math.floor(originalWidth / 2 + resizeHandle.offsetWidth / 2);
+ const delta = originalWidth - expectedWidth;
+
+ await testUtils.dom.dragAndDrop(resizeHandle, th, { mousemoveTarget: window, mouseupTarget: window });
+ const optionalFinalX = Math.floor(optionalDropdown.getBoundingClientRect().x);
+
+ assert.strictEqual(th.offsetWidth, expectedWidth,
+ // 1px for the cell right border
+ "header width should be halved (plus half the width of the handle)");
+ assert.strictEqual(optionalFinalX, optionalInitialX - delta,
+ "optional columns dropdown should have moved the same amount");
+
+ list.destroy();
+ });
});
});
|