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(); + }); }); });